From 38890993a681edbfc3f8d70859ec095781159a0d Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 15:47:56 +0200 Subject: [PATCH 1/9] rowbit connect finds the setup from any project folder Without --dir and with no Rowbit files in the current folder, commands now look at every setup in ~/.rowbit and the demo (linked as ~/.rowbit/demo), use the only one and say which, list them when there are several, and say to run rowbit setup when there are none. So `cd my-app && rowbit connect claude-code` works. Each client entry gets a token of its own, labelled with the client and project, so connecting a second project no longer signs out the first; rowbit agents lists them. Signed-off-by: Ermag --- README.md | 2 +- crates/rowbit-cli/src/connect.rs | 68 ++++++++++---- crates/rowbit-cli/src/demo.rs | 47 ++++++++++ crates/rowbit-cli/src/main.rs | 33 +++++-- crates/rowbit-cli/src/place.rs | 141 +++++++++++++++++++++++++++-- crates/rowbit-cli/src/setup.rs | 18 +++- crates/rowbit-cli/tests/connect.rs | 78 +++++++++++++++- crates/rowbit-cli/tests/serve.rs | 5 +- crates/rowbit-store/src/lib.rs | 125 ++++++++++++++++++++++++- crates/rowbit-store/src/tests.rs | 47 ++++++++++ docs/connect.md | 17 +++- docs/spec/server.md | 2 +- docs/spec/setup.md | 8 +- site/index.html | 4 +- 14 files changed, 542 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 78c3449..2893b6d 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Adds Rowbit to `~/.cursor/mcp.json`. Restart Cursor. rowbit connect vscode ``` -Or, with `rowbit demo` or `rowbit serve` running, [add it to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=rowbit&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22rowbit_token%22%2C%22description%22%3A%22Rowbit%20agent%20token%20%28from%3A%20rowbit%20connect%20vscode%20--print%20--new-token%29%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22http%3A%2F%2F127.0.0.1%3A8080%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24%7Binput%3Arowbit_token%7D%22%7D%7D) and paste the token from `rowbit connect vscode --print --new-token` when VS Code asks (an agent has one token, so this signs out a client the demo connected). +Or, with `rowbit demo` or `rowbit serve` running, [add it to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=rowbit&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22rowbit_token%22%2C%22description%22%3A%22Rowbit%20agent%20token%20%28from%3A%20rowbit%20connect%20vscode%20--print%29%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22http%3A%2F%2F127.0.0.1%3A8080%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24%7Binput%3Arowbit_token%7D%22%7D%7D) and paste the token from `rowbit connect vscode --print` when VS Code asks. diff --git a/crates/rowbit-cli/src/connect.rs b/crates/rowbit-cli/src/connect.rs index 060ceed..d414c6d 100644 --- a/crates/rowbit-cli/src/connect.rs +++ b/crates/rowbit-cli/src/connect.rs @@ -765,8 +765,9 @@ pub struct ConnectArgs { /// Have the client start `rowbit mcp` itself, even with mcp.http on #[arg(long)] stdio: bool, - /// Replace the agent's token, signing out the client that has the old one - #[arg(long)] + /// No longer needed: each client entry gets a token of its own, and + /// connecting the same place again replaces only that one + #[arg(long, hide = true)] new_token: bool, /// Replace an entry of the same name in the client #[arg(long)] @@ -924,10 +925,16 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { a.name ); } + let mut label = connection_label(client, a.scope, &env.cwd); + if a.print { + // Added by hand, maybe somewhere else: don't sign out an entry + // `rowbit connect` added here. + label.push_str(" (printed)"); + } let server = if http { Server::Http { url: format!("{}/mcp", address.base()), - token: issue_token(&a, &agent)?, + token: issue_token(&a, &agent, &label)?, } } else { stdio_server(&a, &agent)? @@ -944,8 +951,8 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { let _ = a .store .open() - .and_then(|mut s| Ok(s.revoke_agent_token(&agent)?)); - eprintln!("rowbit: {agent}'s new token is revoked; it has none now"); + .and_then(|mut s| Ok(s.revoke_connection_token(&agent, &label)?)); + eprintln!("rowbit: the new token for {label} is revoked"); } return Err(e); } @@ -993,11 +1000,20 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { match client { // `rowbit demo` says where to open the client itself. _ if a.dir.is_some() => {} - Client::ClaudeCode if a.scope == Scope::Local => println!( - "It applies only in {}. Start Claude Code there: {}", - env.cwd.display(), - client.open_in(&env.cwd, &env) - ), + Client::ClaudeCode if a.scope == Scope::Local => { + let here = std::env::current_dir().ok().as_deref() == Some(env.cwd.as_path()); + if here { + println!( + "It applies in this folder only. Start Claude Code here, or restart it if it's open, and ask it to use Rowbit." + ) + } else { + println!( + "It applies only in {}. Start Claude Code there: {}", + env.cwd.display(), + client.open_in(&env.cwd, &env) + ) + } + } Client::ClaudeCode | Client::Gemini | Client::Codex => {} _ => println!("Restart {} to load it.", client.label()), } @@ -1105,20 +1121,32 @@ fn pick_agent(a: &ConnectArgs) -> anyhow::Result { } } -/// One token per agent: a new one signs out whoever has the old one, so -/// that takes --new-token. -fn issue_token(a: &ConnectArgs, agent: &str) -> anyhow::Result { +/// A token for this client entry: one per client and place, so connecting +/// the agent in another project leaves the others signed in, and +/// connecting the same place again replaces only its own. +fn issue_token(a: &ConnectArgs, agent: &str, label: &str) -> anyhow::Result { let mut store = rowbit_store::Store::open(&a.store.store) .with_context(|| format!("opening {}", a.store.store.display()))?; - anyhow::ensure!( - a.new_token || store.agent_token_times(agent)?.is_none(), - "{agent} already has a token, which another client may be using; pass --new-token to replace it (that client is signed out), or give this client its own agent in rowbit.yml" - ); - Ok(store.issue_agent_token(agent)?) + Ok(store.issue_connection_token(agent, label)?) +} + +/// Which client, and where it uses Rowbit: "Claude Code in ~/app". +pub fn connection_label(client: Client, scope: Scope, cwd: &Path) -> String { + match (client, scope) { + (Client::ClaudeCode, Scope::Local) => { + format!("{} in {}", client.label(), crate::place::tilde(cwd)) + } + (Client::ClaudeCode, _) => format!("{}, every project", client.label()), + _ => client.label().to_owned(), + } } fn stdio_server(a: &ConnectArgs, agent: &str) -> anyhow::Result { - let here = a.dir.clone().unwrap_or_else(|| PathBuf::from(".")); + let here = a + .dir + .clone() + .or_else(|| crate::place::dir().map(Path::to_owned)) + .unwrap_or_else(|| PathBuf::from(".")); let url = match crate::place::pick_url(a.url.clone())? { Some(url) => Some(url), None => crate::demo::database_url(&here), @@ -1158,7 +1186,7 @@ fn print_address(a: &ConnectArgs) -> anyhow::Result<()> { "turn on mcp.http in rowbit-server.yml for an address to give a client, or name a client for its stdio setup" ); let agent = pick_agent(a)?; - let token = issue_token(a, &agent)?; + let token = issue_token(a, &agent, &format!("{} (printed)", a.name))?; println!("URL: {}/mcp", address.base()); println!("Header: Authorization: Bearer {token}"); Ok(()) diff --git a/crates/rowbit-cli/src/demo.rs b/crates/rowbit-cli/src/demo.rs index 76149ec..dc7bcdf 100644 --- a/crates/rowbit-cli/src/demo.rs +++ b/crates/rowbit-cli/src/demo.rs @@ -103,6 +103,46 @@ struct Entry { } /// The agent's workspace, if `dir` is a demo directory that has one. +/// Whether `dir` is a demo's directory. +pub fn is_demo(dir: &Path) -> bool { + dir.join(MARKER).is_file() +} + +/// Points `~/.rowbit/demo` at the demo's directory, so commands run +/// elsewhere find it. Leaves a setup called `demo` alone. +fn link_demo(dir: &Path) { + let Ok(home) = crate::place::home() else { + return; + }; + let link = home.join(crate::place::DEMO_LINK); + match std::fs::symlink_metadata(&link) { + Ok(m) if m.file_type().is_symlink() => { + if std::fs::read_link(&link).ok().as_deref() == Some(dir) { + return; + } + let _ = std::fs::remove_file(&link); + } + Ok(_) => return, + Err(_) => {} + } + if std::fs::create_dir_all(&home).is_ok() { + let _ = std::os::unix::fs::symlink(dir, &link); + } +} + +/// Removes `~/.rowbit/demo` if it points at `dir`. +fn unlink_demo(dir: &Path) { + let Ok(home) = crate::place::home() else { + return; + }; + let link = home.join(crate::place::DEMO_LINK); + let points_here = std::fs::symlink_metadata(&link).is_ok_and(|m| m.file_type().is_symlink()) + && std::fs::read_link(&link).ok().as_deref() == Some(dir); + if points_here { + let _ = std::fs::remove_file(&link); + } +} + pub fn workspace(dir: &Path) -> Option { let w = dir.join(WORKSPACE); (dir.join(MARKER).is_file() && w.is_dir()).then_some(w) @@ -166,6 +206,7 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { .with_context(|| format!("finding {}", dir.display()))?; let workspace = dir.join(WORKSPACE); std::fs::create_dir_all(&workspace).context("creating the agent's workspace")?; + link_demo(&dir); let address = crate::ServerArgs { config: dir.join("rowbit-server.yml"), } @@ -291,6 +332,9 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { "For the CLI, in {shown}: export ROWBIT_DATABASE_URL={}", marker.database_url )); + say( + "To try it from a project of yours instead, cd into it and run: rowbit connect claude-code (or cursor, codex, ...)", + ); say( "Ctrl-C stops the review page; the database keeps running. `rowbit demo clean` removes it all.", ); @@ -511,6 +555,9 @@ fn clean(dir: &Path) -> anyhow::Result<()> { Err(e) => return Err(e).with_context(|| format!("removing {f}")), } } + if let Ok(canonical) = dir.canonicalize() { + unlink_demo(&canonical); + } // The agent's workspace goes only if it's empty: what's in it is yours. let _ = std::fs::remove_dir(&workspace); if std::fs::remove_dir(dir).is_ok() { diff --git a/crates/rowbit-cli/src/main.rs b/crates/rowbit-cli/src/main.rs index f68f165..d17d77e 100644 --- a/crates/rowbit-cli/src/main.rs +++ b/crates/rowbit-cli/src/main.rs @@ -811,15 +811,34 @@ async fn main() -> anyhow::Result<()> { } None => { let store = store.open()?; + let connections = store.connection_tokens()?; + let used = |u: Option| { + u.map(|u| format!("last used {}", review::when(u))) + .unwrap_or_else(|| "never used".into()) + }; for agent in policy.agents.keys() { - match store.agent_token_times(agent)? { - None => println!("{agent} no token"), - Some((created, used)) => println!( - "{agent} token made {}, {}", + let mine: Vec<_> = + connections.iter().filter(|t| &t.agent == agent).collect(); + let manual = store.agent_token_times(agent)?; + if manual.is_none() && mine.is_empty() { + println!("{agent} no token"); + continue; + } + println!("{agent}"); + if let Some((created, last)) = manual { + println!( + " the agent's own token (rowbit agents token, or rowbit connect before 0.2.3): made {}, {}", review::when(created), - used.map(|u| format!("last used {}", review::when(u))) - .unwrap_or_else(|| "never used".into()) - ), + used(last) + ); + } + for t in mine { + println!( + " {}: made {}, {}", + t.label, + review::when(t.created_ms), + used(t.used_ms) + ); } } } diff --git a/crates/rowbit-cli/src/place.rs b/crates/rowbit-cli/src/place.rs index 3342888..7433aca 100644 --- a/crates/rowbit-cli/src/place.rs +++ b/crates/rowbit-cli/src/place.rs @@ -67,16 +67,145 @@ pub fn init(args: Vec) -> anyhow::Result> { anyhow::ensure!(dir.is_dir(), "{} isn't a directory", dir.display()); Some(dir) } - None if Path::new("rowbit.yml").exists() => None, - None => home() - .ok() - .map(|h| h.join("default")) - .filter(|d| is_setup(d)), + // A folder with Rowbit's own files in it is Rowbit's. + None if ["rowbit.yml", "rowbit-server.yml", "rowbit.db"] + .iter() + .any(|f| Path::new(f).exists()) => + { + None + } + None => { + let found = home().map(|h| setups(&h)).unwrap_or_default(); + match pick(&found) { + Picked::One(s) => { + if s.name != "default" { + eprintln!("rowbit: using {}", s.describe()); + } + Some(s.dir.clone()) + } + Picked::Several if needs_files(&args) => anyhow::bail!( + "Rowbit has several setups and none is the default; pick one with --dir:\n{}", + found + .iter() + .map(|s| format!(" {}", s.describe())) + .collect::>() + .join("\n") + ), + Picked::None if needs_files(&args) => anyhow::bail!( + "no Rowbit setup here: no rowbit.yml in this folder and none in ~/.rowbit. \ + Set one up with `rowbit setup` (or try the sample with `rowbit setup --sample`), \ + or point at one with --dir " + ), + _ => None, + } + } }; let _ = DIR.set(dir); Ok(args) } +/// A setup `rowbit setup` or `rowbit demo` made, found without `--dir`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Found { + /// Its name in `~/.rowbit`, or `demo`. + pub name: String, + pub dir: PathBuf, +} + +impl Found { + pub fn describe(&self) -> String { + let shown = tilde(&self.dir); + if self.name == "demo" { + format!("the demo in {shown}") + } else { + format!("{} in {shown}", self.name) + } + } +} + +/// `path` with the home directory written as `~`. +pub fn tilde(path: &Path) -> String { + match std::env::var_os("HOME").map(PathBuf::from) { + Some(home) if !home.as_os_str().is_empty() => match path.strip_prefix(&home) { + Ok(rest) if rest.as_os_str().is_empty() => "~".into(), + Ok(rest) => format!("~/{}", rest.display()), + Err(_) => path.display().to_string(), + }, + _ => path.display().to_string(), + } +} + +/// Every setup in `rowbit_home` (`~/.rowbit`), by name, and the demo: +/// the one `~/.rowbit/demo` links to, or else `~/rowbit-demo`, where +/// `rowbit setup --sample` run from the home directory puts it. +pub fn setups(rowbit_home: &Path) -> Vec { + let mut found = Vec::new(); + let mut demo = None; + if let Ok(entries) = std::fs::read_dir(rowbit_home) { + let mut entries: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + entries.sort(); + for dir in entries { + let Some(name) = dir.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { + continue; + }; + if is_setup(&dir) { + found.push(Found { name, dir }); + } else if name == DEMO_LINK && crate::demo::is_demo(&dir) { + demo = Some(dir.canonicalize().unwrap_or(dir)); + } + } + } + let demo = demo.or_else(|| { + let d = rowbit_home.parent()?.join("rowbit-demo"); + crate::demo::is_demo(&d).then_some(d) + }); + if let Some(dir) = demo { + found.push(Found { + name: "demo".into(), + dir, + }); + } + found +} + +/// The link in `~/.rowbit` to the demo's directory, which `rowbit demo` +/// makes and `rowbit demo clean` removes. +pub const DEMO_LINK: &str = "demo"; + +#[derive(Debug, PartialEq, Eq)] +enum Picked<'a> { + One(&'a Found), + Several, + None, +} + +/// The default setup if there is one, else the only one. +fn pick(found: &[Found]) -> Picked<'_> { + if let Some(d) = found.iter().find(|s| s.name == "default") { + return Picked::One(d); + } + match found { + [one] => Picked::One(one), + [] => Picked::None, + _ => Picked::Several, + } +} + +/// Whether the command reads a setup's files, so finding none is an error +/// worth stopping for. `ping`, `policy`, `server` and `connect` with no +/// client name (it lists them) work without one. +fn needs_files(args: &[OsString]) -> bool { + let (command, _) = command(args); + match command.as_deref() { + None | Some("ping" | "policy" | "server" | "help") => false, + Some("connect") => args + .iter() + .skip(2) + .any(|a| a == "--print" || !a.to_string_lossy().starts_with('-')), + Some(_) => !args.iter().any(|a| a == "--help" || a == "-h"), + } +} + /// The subcommand, and whether a `--dir` came before it. fn command(args: &[OsString]) -> (Option, bool) { let mut dir_first = false; @@ -135,7 +264,7 @@ pub fn file(path: &mut PathBuf, default: &str) { /// the flag nor the environment set it. pub fn database_url() -> anyhow::Result> { let Some(dir) = dir() else { return Ok(None) }; - read_env_file(&dir.join(ENV_FILE)) + Ok(read_env_file(&dir.join(ENV_FILE))?.or_else(|| crate::demo::database_url(dir))) } /// The database URL a command uses. `--url` wins. Otherwise, in a setup's diff --git a/crates/rowbit-cli/src/setup.rs b/crates/rowbit-cli/src/setup.rs index e3fbd1c..4939806 100644 --- a/crates/rowbit-cli/src/setup.rs +++ b/crates/rowbit-cli/src/setup.rs @@ -126,6 +126,11 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> { connect::check_name(&a.name)?; let dir = place::home()?.join(&a.name); + anyhow::ensure!( + !std::fs::symlink_metadata(&dir).is_ok_and(|m| m.file_type().is_symlink()), + "{} is a link (`rowbit demo` keeps one to its directory there); give this setup another name with --name", + dir.display() + ); let resuming = place::has_rowbit_files(&dir); if resuming { say(&format!( @@ -341,10 +346,17 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> { "Agents can read every table {} can. Limit what each one sees with scopes: https://ermag.github.io/rowbit/docs/spec/policy.html#scopes", found.account )); - if dir != place::home()?.join("default") { + let dir_flag = if dir != place::home()?.join("default") { + format!(" --dir {}", quoted(&dir)) + } else { + String::new() + }; + say(&format!( + "Add Rowbit to another project: cd into it and run rowbit connect claude-code{dir_flag} (or cursor, codex, ...)." + )); + if !dir_flag.is_empty() { say(&format!( - "Other commands find this setup with --dir {}.", - quoted(&dir) + "Other commands find this setup with{dir_flag}, or without it when it's your only one." )); } if !found.own_user { diff --git a/crates/rowbit-cli/tests/connect.rs b/crates/rowbit-cli/tests/connect.rs index ef967dd..e77595a 100644 --- a/crates/rowbit-cli/tests/connect.rs +++ b/crates/rowbit-cli/tests/connect.rs @@ -104,17 +104,17 @@ fn cursor_gets_the_address_and_a_token_of_its_own() { assert!(header.starts_with("Bearer rbt_"), "{file}"); assert!(out.contains(&header[header.len() - 4..]), "{out}"); - // The entry exists, and the agent already has a token. + // The entry exists: replacing it takes --force, and replaces its token. let (ok, out) = c.run(&["connect", "cursor"]); assert!(!ok && out.contains("--force"), "{out}"); - let (ok, out) = c.run(&["connect", "cursor", "--force"]); - assert!(!ok && out.contains("--new-token"), "{out}"); assert_eq!( std::fs::read_to_string(c.home.join(".cursor/mcp.json")).unwrap(), file ); - let (ok, out) = c.run(&["connect", "cursor", "--force", "--new-token"]); + let (ok, out) = c.run(&["connect", "cursor", "--force"]); assert!(ok, "{out}"); + let (_, agents) = c.run(&["agents"]); + assert_eq!(agents.matches(" Cursor: made").count(), 1, "{agents}"); assert_ne!( std::fs::read_to_string(c.home.join(".cursor/mcp.json")).unwrap(), file @@ -153,7 +153,7 @@ fn claude_code_is_set_up_with_its_own_command() { assert!(!ok && out.contains("committed"), "{out}"); let (ok, out) = c.run(&["connect", "claude-code"]); assert!(ok, "{out}"); - assert!(out.contains("Start Claude Code there: cd "), "{out}"); + assert!(out.contains("Start Claude Code here"), "{out}"); assert!(!out.contains("workspace"), "{out}"); let calls = std::fs::read_to_string(&log).unwrap(); assert!(calls.starts_with("mcp get rowbit\n"), "{calls}"); @@ -265,3 +265,71 @@ fn lists_clients_and_prints_an_address() { let token = token_in(&out); assert_eq!(token.len(), 4 + 43, "{out}"); } + +#[test] +fn a_project_folder_finds_the_setup_in_rowbit_home() { + let c = Case::new("anywhere"); + c.http(); + let log = c.fake_claude(); + // The app is a folder of its own; Rowbit's files are elsewhere. + let app = c.dir.parent().unwrap().join("app"); + std::fs::create_dir_all(&app).unwrap(); + let in_app = |args: &[&str]| { + let out = Command::new(env!("CARGO_BIN_EXE_rowbit")) + .args(args) + .current_dir(&app) + .env("HOME", &c.home) + .env("PATH", format!("{}:/usr/bin:/bin", c.bin.display())) + .env_remove("ROWBIT_DATABASE_URL") + .env_remove("ROWBIT_DIR") + .output() + .unwrap(); + ( + out.status.success(), + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ), + ) + }; + let (ok, out) = in_app(&["connect", "claude-code"]); + assert!(!ok && out.contains("rowbit setup"), "{out}"); + // Listing clients needs no setup. + assert!(in_app(&["connect"]).0); + + // One setup, not called default: found, and named. + let shop = c.home.join(".rowbit/shop"); + std::fs::create_dir_all(&shop).unwrap(); + for f in ["rowbit.yml", "rowbit-server.yml"] { + std::fs::copy(c.dir.join(f), shop.join(f)).unwrap(); + } + std::fs::write(shop.join("env"), "ROWBIT_DATABASE_URL=mysql://u:p@h/shop\n").unwrap(); + std::fs::set_permissions( + shop.join("env"), + std::os::unix::fs::PermissionsExt::from_mode(0o600), + ) + .unwrap(); + let (ok, out) = in_app(&["connect", "claude-code"]); + assert!(ok, "{out}"); + assert!(out.contains("using shop in ~/.rowbit/shop"), "{out}"); + assert!(out.contains("Start Claude Code here"), "{out}"); + assert!( + std::fs::read_to_string(&log) + .unwrap() + .contains("mcp add --scope local") + ); + let (_, agents) = in_app(&["agents"]); + assert!(agents.contains("Claude Code in "), "{agents}"); + + // Two, and neither the default: Rowbit asks which. + std::fs::create_dir_all(c.home.join(".rowbit/other")).unwrap(); + for f in ["rowbit.yml", "env"] { + std::fs::copy(shop.join(f), c.home.join(".rowbit/other").join(f)).unwrap(); + } + let (ok, out) = in_app(&["changes"]); + assert!( + !ok && out.contains("--dir") && out.contains("shop in ~/.rowbit/shop"), + "{out}" + ); +} diff --git a/crates/rowbit-cli/tests/serve.rs b/crates/rowbit-cli/tests/serve.rs index 0d28641..a0f90eb 100644 --- a/crates/rowbit-cli/tests/serve.rs +++ b/crates/rowbit-cli/tests/serve.rs @@ -8,6 +8,8 @@ fn rowbit(args: &[&str], dir: &std::path::Path) -> (bool, String) { .args(args) .current_dir(dir) .env("ROWBIT_DATABASE_URL", "mysql://nobody@127.0.0.1:1/none") + // No setups of the person running the tests. + .env("HOME", dir) .env_remove("ROWBIT_SERVER_CONFIG") .output() .unwrap(); @@ -29,6 +31,7 @@ fn dir(name: &str) -> std::path::PathBuf { #[test] fn no_network_listening_without_an_identity_provider() { let d = dir("net"); + std::fs::write(d.join("rowbit.yml"), "version: 0\nagents: {}\n").unwrap(); let (ok, out) = rowbit(&["serve", "--listen", "0.0.0.0:0"], &d); assert!(!ok, "{out}"); assert!(out.contains("identity provider"), "{out}"); @@ -72,7 +75,7 @@ fn agent_tokens_are_shown_once_and_revocable() { assert_eq!(token.len(), 47); let (ok, out) = rowbit(&["agents"], &d); assert!( - ok && out.contains("refund-bot token made") && out.contains("never used"), + ok && out.contains("refund-bot\n the agent's own token") && out.contains("never used"), "{out}" ); assert!(!out.contains(&token)); diff --git a/crates/rowbit-store/src/lib.rs b/crates/rowbit-store/src/lib.rs index cee3a09..8f0846f 100644 --- a/crates/rowbit-store/src/lib.rs +++ b/crates/rowbit-store/src/lib.rs @@ -140,6 +140,55 @@ pub enum DeliveryOutcome { Failed { error: String }, } +/// A token `rowbit connect` made for one client entry: an agent may have +/// one per place it is connected, each labelled with where it is. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConnectionToken { + pub agent: String, + /// Which client, and where: "Claude Code in ~/projects/app". + pub label: String, + pub created_ms: i64, + pub used_ms: Option, +} + +const CONNECTION: &str = "connection."; + +/// Every connection token, by its hash. +fn connection_tokens(conn: &Connection) -> Result, StoreError> { + let mut stmt = conn.prepare( + "SELECT key, value FROM settings WHERE substr(key, 1, length(?1)) = ?1 ORDER BY key", + )?; + let rows = stmt.query_map([CONNECTION], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })?; + let mut out = Vec::new(); + for row in rows { + let (key, value) = row?; + let t: ConnectionToken = + serde_json::from_str(&value).map_err(|e| StoreError::Corrupt(format!("{key}: {e}")))?; + out.push((key[CONNECTION.len()..].to_owned(), t)); + } + Ok(out) +} + +/// Removes the connection tokens `remove` picks, inside a transaction. +fn revoke_connections( + tx: &rusqlite::Transaction<'_>, + remove: impl Fn(&ConnectionToken) -> bool, +) -> Result { + let mut any = false; + for (hash, t) in connection_tokens(tx)? { + if remove(&t) { + tx.execute( + "DELETE FROM settings WHERE key IN (?1, ?2)", + params![format!("token.{hash}"), format!("{CONNECTION}{hash}")], + )?; + any = true; + } + } + Ok(any) +} + /// Removes `agent`'s token and its times, inside a transaction. fn revoke_token(tx: &rusqlite::Transaction<'_>, agent: &str) -> Result { let old: Option = tx @@ -645,16 +694,82 @@ impl Store { Ok(token) } - /// Removes `agent`'s MCP token. Returns whether it had one. + /// Removes every MCP token of `agent`'s, the one `rowbit agents token` + /// made and every connection's. Returns whether it had any. pub fn revoke_agent_token(&mut self, agent: &str) -> Result { let tx = self .conn .transaction_with_behavior(TransactionBehavior::Immediate)?; let had = revoke_token(&tx, agent)?; + let had_connections = revoke_connections(&tx, |t| t.agent == agent)?; + tx.commit()?; + Ok(had || had_connections) + } + + /// Makes a new MCP token for one client entry of `agent`'s, replacing + /// the one with the same label, and returns it. The agent's other + /// tokens keep working. Only its SHA-256 is kept. + pub fn issue_connection_token( + &mut self, + agent: &str, + label: &str, + ) -> Result { + use base64::Engine as _; + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("the system has a random source"); + let token = format!( + "rbt_{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ); + let hash = hex(&Sha256::digest(token.as_bytes())); + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + revoke_connections(&tx, |t| t.agent == agent && t.label == label)?; + let info = ConnectionToken { + agent: agent.to_owned(), + label: label.to_owned(), + created_ms: now_ms(), + used_ms: None, + }; + tx.execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2), (?3, ?4)", + params![ + format!("token.{hash}"), + agent, + format!("{CONNECTION}{hash}"), + serde_json::to_string(&info).expect("serializes"), + ], + )?; + tx.commit()?; + Ok(token) + } + + /// Removes the token of `agent`'s connection with this label. Returns + /// whether there was one. + pub fn revoke_connection_token( + &mut self, + agent: &str, + label: &str, + ) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let had = revoke_connections(&tx, |t| t.agent == agent && t.label == label)?; tx.commit()?; Ok(had) } + /// Every connection's token, by agent and label. + pub fn connection_tokens(&self) -> Result, StoreError> { + let mut out: Vec = connection_tokens(&self.conn)? + .into_iter() + .map(|(_, t)| t) + .collect(); + out.sort_by(|a, b| (&a.agent, &a.label).cmp(&(&b.agent, &b.label))); + Ok(out) + } + /// The agent a token belongs to, and notes that it was used. `None` for /// a token Rowbit didn't issue or has revoked. pub fn agent_for_token(&mut self, token: &str) -> Result, StoreError> { @@ -662,6 +777,14 @@ impl Store { let Some(agent) = self.setting(&format!("token.{hash}"))? else { return Ok(None); }; + let key = format!("{CONNECTION}{hash}"); + if let Some(value) = self.setting(&key)? { + let mut info: ConnectionToken = serde_json::from_str(&value) + .map_err(|e| StoreError::Corrupt(format!("{key}: {e}")))?; + info.used_ms = Some(now_ms()); + self.set_setting(&key, &serde_json::to_string(&info).expect("serializes"))?; + return Ok(Some(agent)); + } self.set_setting( &format!("agent.{agent}.token_used_ms"), &now_ms().to_string(), diff --git a/crates/rowbit-store/src/tests.rs b/crates/rowbit-store/src/tests.rs index ee901e7..86a4163 100644 --- a/crates/rowbit-store/src/tests.rs +++ b/crates/rowbit-store/src/tests.rs @@ -546,6 +546,53 @@ fn agent_tokens_are_kept_as_hashes_and_replaced() { assert!(!store.revoke_agent_token("refund-bot").unwrap()); } +#[test] +fn each_connection_has_a_token_of_its_own() { + let mut store = Store::open_in_memory().unwrap(); + let manual = store.issue_agent_token("bot").unwrap(); + let app = store + .issue_connection_token("bot", "Claude Code in ~/app") + .unwrap(); + let web = store + .issue_connection_token("bot", "Claude Code in ~/web") + .unwrap(); + // Each works, and none signs another out. + for t in [&manual, &app, &web] { + assert_eq!(store.agent_for_token(t).unwrap().as_deref(), Some("bot")); + } + let list = store.connection_tokens().unwrap(); + assert_eq!( + list.iter().map(|t| t.label.as_str()).collect::>(), + ["Claude Code in ~/app", "Claude Code in ~/web"] + ); + assert!(list.iter().all(|t| t.used_ms.is_some())); + + // Connecting the same place again replaces only that one. + let app2 = store + .issue_connection_token("bot", "Claude Code in ~/app") + .unwrap(); + assert_eq!(store.agent_for_token(&app).unwrap(), None); + assert_eq!( + store.agent_for_token(&app2).unwrap().as_deref(), + Some("bot") + ); + assert_eq!(store.agent_for_token(&web).unwrap().as_deref(), Some("bot")); + assert_eq!(store.connection_tokens().unwrap().len(), 2); + + assert!( + store + .revoke_connection_token("bot", "Claude Code in ~/web") + .unwrap() + ); + assert_eq!(store.agent_for_token(&web).unwrap(), None); + + // Revoking the agent signs out every one. + assert!(store.revoke_agent_token("bot").unwrap()); + assert_eq!(store.agent_for_token(&manual).unwrap(), None); + assert_eq!(store.agent_for_token(&app2).unwrap(), None); + assert!(store.connection_tokens().unwrap().is_empty()); +} + #[test] fn settings_are_listed_by_prefix_and_deleted() { let mut store = Store::open_in_memory().unwrap(); diff --git a/docs/connect.md b/docs/connect.md index ce66a53..b226979 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -1,12 +1,25 @@ # Connect your agent -Agents reach Rowbit over MCP. [`rowbit setup`](quickstart.md) connects the clients you pick as it sets Rowbit up, and connects more when run again. `rowbit connect` sets up one client, as one agent from `rowbit.yml`. Run it where `rowbit serve` runs, or pass `--dir` (in the demo, inside `rowbit-demo/`): +Agents reach Rowbit over MCP. [`rowbit setup`](quickstart.md) connects the clients you pick as it sets Rowbit up, and connects more when run again. `rowbit connect` sets up one client, as one agent from `rowbit.yml`: ```sh rowbit connect # lists the clients Rowbit knows, and which are installed rowbit connect claude-code ``` +## Add Rowbit to a project you already have + +Set Rowbit up once with `rowbit setup` (or try it with the demo), then go to your project and connect your agent there: + +```sh +cd ~/projects/my-app +rowbit connect claude-code +``` + +Rowbit finds its setup on its own: the folder's `rowbit.yml` if it has one, else `~/.rowbit/default`, else your only setup in `~/.rowbit` or the demo, and says which it used. With several and no `default`, it lists them and asks for `--dir`. For Claude Code the entry applies to this project only, so other projects don't see Rowbit; `--scope user` adds it everywhere. Each project you connect gets a token of its own, so connecting a second project doesn't sign out the first, and `rowbit agents` lists them all. + +Rowbit doesn't write `.mcp.json` (Claude Code's project scope), since that file is meant to be committed and would carry the token. + | Client | `rowbit connect …` | What it changes | |---|---|---| | Claude Code | `claude-code` | Runs `claude mcp add`, for this directory (`--scope local`, the default) or for you everywhere (`--scope user`) | @@ -22,7 +35,7 @@ With `mcp.http: true` in `rowbit-server.yml` (the demo has it), the client gets `rowbit connect` keeps a backup of any file it changes (`.rowbit-backup`), refuses a file it can't parse, and replaces nothing without being told: - `--force` replaces an entry of the same name; `--name ` adds another. -- `--new-token` replaces the agent's token. An agent has one token, so this signs out the client that had the old one. After `rowbit demo` connected a client, adding another (or using an install button) takes `--print --new-token`. To connect two clients at once, give each its own agent in `rowbit.yml`. +- Each client entry gets a token of its own, named for the client and, for Claude Code, the project: "Claude Code in ~/projects/my-app". Connecting the same place again (with `--force`) replaces only that one. `rowbit agents` lists them, and `rowbit agents revoke ` signs out all of an agent's. - `--agent ` picks the agent when `rowbit.yml` lists more than one. - `--print` changes nothing in the client and prints what to add by hand, with the token. diff --git a/docs/spec/server.md b/docs/spec/server.md index 3ed0f1a..67c9228 100644 --- a/docs/spec/server.md +++ b/docs/spec/server.md @@ -114,7 +114,7 @@ With `mcp.http: true`, `rowbit serve` also serves agents at `/mcp` o Each agent signs in with a token of its own, sent as `Authorization: Bearer `: -- `rowbit agents token ` makes a new token for an agent listed in `rowbit.yml`, prints it once, and keeps only its SHA-256 hash in the state file. Making a new one replaces the old. `rowbit agents revoke ` removes it. `rowbit agents` lists which agents have a token and when it was last used. +- `rowbit agents token ` makes a new token for an agent listed in `rowbit.yml`, prints it once, and keeps only its SHA-256 hash in the state file. Making a new one replaces the old. `rowbit connect` makes one per client entry instead, labelled with where it is (see [rowbit connect](setup.md#rowbit-connect)), so an agent can be connected in several places at once. `rowbit agents revoke ` removes all of the agent's tokens. `rowbit agents` lists each agent's tokens and when each was last used. - Tokens are 32 random bytes, written as `rbt_` and 43 characters of base64url. - The agent's identity is the token's agent, never anything in the request. A missing, unknown or revoked token, or a token for an agent no longer in `rowbit.yml`, gets `401` and nothing else. Failed sign-ins are slowed down as on the review page. - `/mcp` answers only requests addressed to the configured host, and refuses any request with an `Origin` header that isn't `public_url`, so a web page can't reach it from someone's browser. diff --git a/docs/spec/setup.md b/docs/spec/setup.md index 255940e..b094aa3 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -82,7 +82,7 @@ It never runs a statement that changes the database or its users. Where somethin ## Where commands find their files -Every command but `demo` and `setup` takes `--dir ` (or `ROWBIT_DIR`), after the command's name, and then finds `rowbit.yml`, `rowbit-server.yml`, `rowbit.db` and the `env` file in that directory. Without it, a command uses the current directory if it holds a `rowbit.yml`, as in v0.2.1, and otherwise `~/.rowbit/default` if that holds a setup. A flag or variable naming one file (`--policy`, `--store`, `--config` and their variables) still wins for that file. `--dir` before `demo` or `setup` is refused, since they have options of their own. +Every command but `demo` and `setup` takes `--dir ` (or `ROWBIT_DIR`), after the command's name, and then finds `rowbit.yml`, `rowbit-server.yml`, `rowbit.db` and the `env` file in that directory. Without it, a command uses the current directory if it holds a `rowbit.yml`, `rowbit-server.yml` or `rowbit.db`, and otherwise `~/.rowbit/default` if that holds a setup. Without that either, it looks at every setup in `~/.rowbit` and the demo (the one `~/.rowbit/demo` links to, which `rowbit demo` makes and `rowbit demo clean` removes, or else `~/rowbit-demo`): if there is one, it uses it and says which on standard error; if there are several, it lists them and asks for `--dir`; if there are none, it says to run `rowbit setup`. `ping`, `policy`, `server` and `rowbit connect` without a client work without a setup. So `rowbit connect claude-code` works from any project folder. `rowbit setup` refuses a name that is a link, so it never writes into the demo's directory. A flag or variable naming one file (`--policy`, `--store`, `--config` and their variables) still wins for that file. `--dir` before `demo` or `setup` is refused, since they have options of their own. The database URL is `--url` if given. Otherwise, in a setup's directory, it's the `env` file's, even when `ROWBIT_DATABASE_URL` is set: that may be left in the shell from the demo, and pairing a setup's state with another database would go unnoticed. Rowbit says so on standard error when the two differ. Without an `env` file, `ROWBIT_DATABASE_URL` is used, as in v0.2.1. Rowbit refuses an `env` file that others can read, since it holds the database's password. @@ -113,7 +113,7 @@ The database URL is `--url` if given. Otherwise, in a setup's directory, it's th 1. **The directory.** If it doesn't exist, Rowbit creates it and writes the quickstart's `compose.yaml`, `shop.sql` and `rowbit.yml` (built into the binary from `examples/quickstart`, with `--port` in place of `33070`), a `rowbit-server.yml` with `mcp.http: true` and the `--listen` address, `.rowbit-demo`, which marks the directory as the demo's and records the database URL and which client entries the demo added, and an empty `workspace/` for the agent. The agent works there, not beside the files that say how to reach the database around Rowbit (`compose.yaml` holds the database's root password). If it exists with `.rowbit-demo` in it, Rowbit keeps every file as it is, so changes to `rowbit.yml` survive a restart. If it exists, isn't empty and has no marker, Rowbit refuses, so it never writes into a directory of yours. 2. **The database.** `docker compose up -d --wait` in that directory. MariaDB listens on `127.0.0.1:` only, with the quickstart's fixed passwords, which is why the demo is for trying Rowbit and nothing else. 3. **Something to review.** If the state file has no changes yet, Rowbit proposes the README's three writes as `coding-agent`, as `rowbit propose` would: a small fix that is applied (change 1), the risky update that is held (change 2) and a delete that is refused (change 3). The review page has something on it before any agent connects, and the tour carries on from there. -4. **An agent.** On first start, Rowbit lists the [clients](#rowbit-connect) it finds (the client's command on `PATH`, or its configuration directory present) and asks which to connect, the first found by default, or none. Then it does what `rowbit connect ` does, inside the demo directory, with Claude Code's local scope on `workspace/`. If that fails (a client file Rowbit can't parse, an entry by that name already there), it says why and how to connect later, and carries on without a client. One client, because the demo has one agent and an agent has one token; `rowbit connect --new-token` in the directory moves it. `--connect ` picks one without asking, `--no-connect` skips this, and without a terminal Rowbit skips it unless `--connect` is given. On a restart with a client already connected, it skips this. +4. **An agent.** On first start, Rowbit lists the [clients](#rowbit-connect) it finds (the client's command on `PATH`, or its configuration directory present) and asks which to connect, the first found by default, or none. Then it does what `rowbit connect ` does, inside the demo directory, with Claude Code's local scope on `workspace/`. If that fails (a client file Rowbit can't parse, an entry by that name already there), it says why and how to connect later, and carries on without a client. One client here; `rowbit connect ` adds more, each with a token of its own. `--connect ` picks one without asking, `--no-connect` skips this, and without a terminal Rowbit skips it unless `--connect` is given. On a restart with a client already connected, it skips this. 5. **The review page.** Rowbit makes a new page password, then runs `rowbit serve` in that directory in the foreground, with `/mcp` on. A new password each start, since the directory is throwaway and a lost password would otherwise mean `--set-password`. 6. **What to try.** Before serving, it prints the review page's address and password, and what to ask the agent: @@ -153,7 +153,7 @@ Compose names the project after the directory, so two demo directories with the - **The agent** is `--agent `, or the only agent in `rowbit.yml`. With more than one and no `--agent`, Rowbit lists their ids and stops. - **Over HTTP** when `rowbit-server.yml` has `mcp.http: true`. The address is `/mcp`, or `http:///mcp` without a `public_url`. Rowbit makes the agent a token as `rowbit agents token` does and writes it into the client's configuration as `Authorization: Bearer `; the client's machine gets nothing else. If nothing answers at the address, it says to start `rowbit serve`. - **Over stdio** otherwise, with `--stdio`, and always for Claude Desktop. The client starts `rowbit mcp --agent ` with full paths to the binary, `rowbit.yml` and `rowbit.db`, and `ROWBIT_DATABASE_URL` from the environment, or from `.rowbit-demo` in the demo directory. Rowbit prints that the client's configuration now holds the database URL and points at the state file, and that anyone who can read them can act around Rowbit (see [Before you use it for real](../quickstart.md#before-you-use-it-for-real)). -- **One token per agent.** If the agent already has a token, Rowbit refuses unless `--new-token` is given, because a new token signs out the client using the old one. To connect two clients at once, give each its own agent in `rowbit.yml`. +- **One token per client entry.** Over HTTP, each entry gets a token of its own, labelled with the client and, for Claude Code's local scope, the directory: "Claude Code in ~/projects/app", "Claude Code, every project", "Cursor". A new token for the same agent and label replaces the old one; the agent's other tokens keep working. `--print` labels its token "(printed)", so it never signs out an entry `rowbit connect` added. `--new-token`, which v0.2.2 needed to replace an agent's only token, is accepted and does nothing. - **The entry is named `rowbit`,** or `--name `. If the client already has an entry by that name, Rowbit refuses unless `--force` is given, which replaces it. | Client | What Rowbit does | @@ -178,7 +178,7 @@ When Rowbit edits a client's file itself, it: Every run prints what it changed and where, with the token shortened to `rbt_` and its last four characters, and says to restart the client if it only reads its configuration on start. -`rowbit connect --check` changes nothing and makes no token. It says whether the client has the entry (`claude mcp get` from the directory the entry applies to, which in a demo directory is `workspace/`, or the client's file), then, over HTTP without a `public_url`, whether anything answers at the address, and for Claude Code the line that starts it in that directory. It exits non-zero if the entry is missing or nothing answers. It can't be combined with `--print`, `--force`, `--new-token` or `--stdio`. +`rowbit connect --check` changes nothing and makes no token. It says whether the client has the entry (`claude mcp get` from the directory the entry applies to, which in a demo directory is `workspace/`, or the client's file), then, over HTTP without a `public_url`, whether anything answers at the address, and for Claude Code the line that starts it in that directory. It exits non-zero if the entry is missing or nothing answers. It can't be combined with `--print`, `--force` or `--stdio`. `rowbit connect --print` changes no client configuration: it prints the command to run or the entry to add, with the full token, for setting up by hand or on another machine. It still makes the token. `rowbit connect --print` without a client prints the address and the token, for any other MCP client. diff --git a/site/index.html b/site/index.html index 48a9cf6..bc2194a 100644 --- a/site/index.html +++ b/site/index.html @@ -293,7 +293,7 @@

GET STARTED: TWO COMMANDS

Already running Rowbit?
-

Setup connects the clients you pick. To add VS Code from here, use the button and paste the token from rowbit connect vscode --print --new-token when VS Code asks (an agent has one token, so this signs out the other client). The button assumes the default 127.0.0.1:8080.

+

Setup connects the clients you pick. To add VS Code from here, use the button and paste the token from rowbit connect vscode --print when VS Code asks. The button assumes the default 127.0.0.1:8080.

▶ ADD TO VS CODE
@@ -467,7 +467,7 @@

INSERT COIN: YOUR OPINION

})); // A link is the same for everyone, so it can't carry an agent's token: // VS Code asks for it, as a password, when the server is added. -const vsInputs = [{ type: 'promptString', id: 'rowbit_token', description: 'Rowbit agent token (from: rowbit connect vscode --print --new-token)', password: true }]; +const vsInputs = [{ type: 'promptString', id: 'rowbit_token', description: 'Rowbit agent token (from: rowbit connect vscode --print)', password: true }]; const vsConfig = { type: 'http', url: 'http://127.0.0.1:8080/mcp', headers: { Authorization: 'Bearer ${input:rowbit_token}' } }; $('#vscode').href = 'https://insiders.vscode.dev/redirect/mcp/install?name=rowbit&inputs=' + encodeURIComponent(JSON.stringify(vsInputs)) + '&config=' + encodeURIComponent(JSON.stringify(vsConfig)); From 5451076c30daef5ded3912252f28ed5c73391948 Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 15:52:21 +0200 Subject: [PATCH 2/9] Clickable links in the terminal, and the demo opens the review page Review page addresses and docs links are OSC 8 links in terminals known to show them (Ghostty, iTerm2, WezTerm, kitty, VS Code, ...), plain elsewhere; ROWBIT_HYPERLINKS overrides. rowbit demo opens the review page in the browser once it answers (--no-open skips). The MCP instructions and the skill ask agents to give the review link as a Markdown link. Signed-off-by: Ermag --- crates/rowbit-cli/src/demo.rs | 11 +- crates/rowbit-cli/src/main.rs | 6 +- crates/rowbit-cli/src/setup.rs | 12 ++- crates/rowbit-cli/src/term.rs | 141 ++++++++++++++++++++++++++ crates/rowbit-mcp/src/lib.rs | 4 +- docs/spec/setup.md | 8 +- plugins/rowbit/skills/rowbit/SKILL.md | 2 +- 7 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 crates/rowbit-cli/src/term.rs diff --git a/crates/rowbit-cli/src/demo.rs b/crates/rowbit-cli/src/demo.rs index dc7bcdf..a199537 100644 --- a/crates/rowbit-cli/src/demo.rs +++ b/crates/rowbit-cli/src/demo.rs @@ -62,6 +62,9 @@ pub struct DemoArgs { /// Don't connect an agent client #[arg(long)] no_connect: bool, + /// Don't open the review page in the browser + #[arg(long)] + no_open: bool, } #[derive(clap::Subcommand)] @@ -273,7 +276,8 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { println!(); say("Rowbit is guarding the demo shop database."); say(&format!( - "Review page: {address} password: {password} name: anything" + "Review page: {} password: {password} name: anything", + crate::term::link(&address) )); println!(); match (&connected, &open) { @@ -347,6 +351,11 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { println!(); std::io::stdout().flush()?; + if !a.no_open && std::io::stdin().is_terminal() && std::io::stdout().is_terminal() { + say("Opening the review page in your browser (--no-open skips this)."); + println!(); + crate::term::open_when_up(address.clone()); + } std::env::set_current_dir(&dir)?; crate::serve( None, diff --git a/crates/rowbit-cli/src/main.rs b/crates/rowbit-cli/src/main.rs index d17d77e..ae7ebd0 100644 --- a/crates/rowbit-cli/src/main.rs +++ b/crates/rowbit-cli/src/main.rs @@ -4,6 +4,7 @@ mod grants; mod place; mod review; mod setup; +mod term; use std::path::PathBuf; @@ -399,7 +400,10 @@ async fn serve( mcp: config.mcp_http.then_some(database), }; let address = address.trim_end_matches('/'); - eprintln!("rowbit: review page on {address}/"); + eprintln!( + "rowbit: review page on {}", + term::link_err(&format!("{address}/")) + ); if config.mcp_http { eprintln!("rowbit: agents over MCP at {address}/mcp, each with its own token"); } diff --git a/crates/rowbit-cli/src/setup.rs b/crates/rowbit-cli/src/setup.rs index 4939806..042118e 100644 --- a/crates/rowbit-cli/src/setup.rs +++ b/crates/rowbit-cli/src/setup.rs @@ -12,6 +12,7 @@ use rowbit_mysql::grants::{self, Account, Grant, Object}; use crate::connect::{self, Client, Env, Scope}; use crate::place; +use crate::term; /// What Rowbit's own user needs on each database, as in docs/privileges.md. const NEEDED: [&str; 6] = [ @@ -313,10 +314,12 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> { } match &password { Some(p) => say(&format!( - "Review page: {address} password: {p} (shown this once)" + "Review page: {} password: {p} (shown this once)", + term::link(&address) )), None => say(&format!( - "Review page: {address} (password as set before)" + "Review page: {} (password as set before)", + term::link(&address) )), } for (client, scope) in &connected { @@ -343,8 +346,9 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> { } say("Writes wait for you on the review page until you add rules to rowbit.yml."); say(&format!( - "Agents can read every table {} can. Limit what each one sees with scopes: https://ermag.github.io/rowbit/docs/spec/policy.html#scopes", - found.account + "Agents can read every table {} can. Limit what each one sees with scopes: {}", + found.account, + term::link("https://ermag.github.io/rowbit/docs/spec/policy.html#scopes") )); let dir_flag = if dir != place::home()?.join("default") { format!(" --dir {}", quoted(&dir)) diff --git a/crates/rowbit-cli/src/term.rs b/crates/rowbit-cli/src/term.rs new file mode 100644 index 0000000..692df25 --- /dev/null +++ b/crates/rowbit-cli/src/term.rs @@ -0,0 +1,141 @@ +//! Links people can click in a terminal. Plain URLs depend on the +//! terminal spotting them, and some only open them on ⌘-click or not at +//! all; OSC 8 marks the text as a link outright. See "Links in the +//! terminal" in docs/spec/setup.md. + +use std::io::IsTerminal; + +/// `url` as a link where the terminal is known to show OSC 8 links, and +/// as it is otherwise (a pipe, a log, an unknown terminal). +pub fn link(url: &str) -> String { + link_if(url, std::io::stdout().is_terminal()) +} + +/// [`link`], for standard error. +pub fn link_err(url: &str) -> String { + link_if(url, std::io::stderr().is_terminal()) +} + +fn link_if(url: &str, terminal: bool) -> String { + if terminal && supports(|k| std::env::var(k).ok()) { + osc8(url, url) + } else { + url.to_owned() + } +} + +fn osc8(url: &str, text: &str) -> String { + format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\") +} + +/// Whether the environment names a terminal that shows OSC 8 links. +/// `ROWBIT_HYPERLINKS=0` or `1` decides; otherwise a terminal on the list, +/// and never inside tmux or screen, which drop the sequence unless set up +/// to pass it on. +fn supports(var: impl Fn(&str) -> Option) -> bool { + match var("ROWBIT_HYPERLINKS").as_deref() { + Some("0") => return false, + Some("1") => return true, + _ => {} + } + if var("TMUX").is_some() || var("STY").is_some() { + return false; + } + if var("TERM").as_deref() == Some("dumb") { + return false; + } + let program = var("TERM_PROGRAM").unwrap_or_default(); + matches!( + program.as_str(), + "ghostty" | "iTerm.app" | "WezTerm" | "vscode" | "Hyper" | "rio" | "WarpTerminal" + ) || var("TERM").is_some_and(|t| t == "xterm-kitty" || t == "xterm-ghostty") + || var("KITTY_WINDOW_ID").is_some() + || var("WT_SESSION").is_some() + || var("VTE_VERSION") + .and_then(|v| v.parse::().ok()) + .is_some_and(|v| v >= 5000) +} + +/// Opens `url` in the default browser. Returns whether that started. +pub fn open_browser(url: &str) -> bool { + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + std::process::Command::new(opener) + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok() +} + +/// Opens `url` in the browser once something answers there, from a +/// thread of its own, giving up after ten seconds. +pub fn open_when_up(url: String) { + std::thread::spawn(move || wait_and_open(&url, 50)); +} + +/// Opens `url` in the browser once something answers there, trying +/// `tries` times, 200ms apart. Returns whether it opened. +pub fn wait_and_open(url: &str, tries: u32) -> bool { + let Some(addr) = url + .strip_prefix("http://") + .and_then(|rest| rest.split('/').next()) + .and_then(|host| std::net::ToSocketAddrs::to_socket_addrs(host).ok()) + .and_then(|mut a| a.next()) + else { + return false; + }; + for _ in 0..tries { + if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)) + .is_ok() + { + return open_browser(url); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |k| { + vars.iter() + .find(|(n, _)| *n == k) + .map(|(_, v)| (*v).to_owned()) + } + } + + #[test] + fn known_terminals_get_links() { + assert!(supports(env(&[("TERM_PROGRAM", "ghostty")]))); + assert!(supports(env(&[("TERM_PROGRAM", "iTerm.app")]))); + assert!(supports(env(&[("TERM", "xterm-kitty")]))); + assert!(supports(env(&[("VTE_VERSION", "7600")]))); + assert!(!supports(env(&[("TERM_PROGRAM", "Apple_Terminal")]))); + assert!(!supports(env(&[]))); + assert!(!supports(env(&[ + ("TERM_PROGRAM", "ghostty"), + ("TMUX", "/tmp/x") + ]))); + assert!(!supports(env(&[ + ("TERM_PROGRAM", "ghostty"), + ("ROWBIT_HYPERLINKS", "0") + ]))); + assert!(supports(env(&[("ROWBIT_HYPERLINKS", "1")]))); + } + + #[test] + fn a_link_shows_its_address() { + assert_eq!( + osc8("http://127.0.0.1:8080/", "http://127.0.0.1:8080/"), + "\x1b]8;;http://127.0.0.1:8080/\x1b\\http://127.0.0.1:8080/\x1b]8;;\x1b\\" + ); + } +} diff --git a/crates/rowbit-mcp/src/lib.rs b/crates/rowbit-mcp/src/lib.rs index 6d71542..7172880 100644 --- a/crates/rowbit-mcp/src/lib.rs +++ b/crates/rowbit-mcp/src/lib.rs @@ -29,7 +29,9 @@ send one INSERT, UPDATE or DELETE (or a schema change) to `propose_write`. \ Rowbit previews it without running it and checks it against the team's \ rules: it may be applied at once, held for a person, or rejected with a \ reason. You cannot approve changes; a person does. When a change is held, \ -give the person its `review_url`, say what it changes, and stop: don't \ +give the person its `review_url` as a Markdown link, such as \ +[Review change 4](http://127.0.0.1:8080/changes/4), so they can click it, \ +say what it changes, and stop: don't \ retry the write in another form or split it to get under a rule. Follow \ it with `change_status`."; diff --git a/docs/spec/setup.md b/docs/spec/setup.md index b094aa3..ff543b4 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -188,7 +188,13 @@ The home page and the README also offer install links for VS Code (`https://insi ## Review links -So that a held write ends with the person knowing where to look, `propose_write` and `change_status` return `review_url` for a held change: `/changes/`, or `http:///changes/` without a `public_url`, the same link notifications use. `rowbit serve` records that address in the state file when it starts, so `rowbit mcp` over stdio, which shares the file, links to the same place; before `rowbit serve` has ever run, the link uses `http://127.0.0.1:8080`. The server's instructions tell the agent to give the person that link and not to retry the write in another form. The link reveals nothing: the page needs sign-in, and the agent could reach its address already. Opened signed out, it goes through sign-in (carrying only the change's number) and then to that change. See [Agent tools](change-lifecycle.md#agent-tools-mcp). +So that a held write ends with the person knowing where to look, `propose_write` and `change_status` return `review_url` for a held change: `/changes/`, or `http:///changes/` without a `public_url`, the same link notifications use. `rowbit serve` records that address in the state file when it starts, so `rowbit mcp` over stdio, which shares the file, links to the same place; before `rowbit serve` has ever run, the link uses `http://127.0.0.1:8080`. The server's instructions tell the agent to give the person that link, as a Markdown link so clients that render Markdown make it clickable, and not to retry the write in another form. The link reveals nothing: the page needs sign-in, and the agent could reach its address already. Opened signed out, it goes through sign-in (carrying only the change's number) and then to that change. See [Agent tools](change-lifecycle.md#agent-tools-mcp). + +## Links in the terminal + +A plain URL in a terminal is only clickable if the terminal spots it, and some open it only on ⌘-click or Ctrl-click. So where Rowbit prints an address for a person (the review page in `setup`, `demo` and `serve`, the docs link in `setup`), it marks it as a link with OSC 8, in terminals known to show those: Ghostty, iTerm2, WezTerm, kitty, VS Code's terminal, Windows Terminal, Hyper, Rio, Warp and VTE-based ones (GNOME Terminal and others). It prints the address as it is when the output isn't a terminal, in Terminal.app, which doesn't support them, and inside tmux or screen, which drop them unless set up to pass them on. `ROWBIT_HYPERLINKS=1` or `0` overrides the choice. The link's text is the address itself, so copying still works. + +`rowbit demo` also opens the review page in the browser once it answers, when run in a terminal; `--no-open` skips that. ## The skill diff --git a/plugins/rowbit/skills/rowbit/SKILL.md b/plugins/rowbit/skills/rowbit/SKILL.md index 00c2e0f..3a9c637 100644 --- a/plugins/rowbit/skills/rowbit/SKILL.md +++ b/plugins/rowbit/skills/rowbit/SKILL.md @@ -31,7 +31,7 @@ Send one `INSERT`, `UPDATE` or `DELETE` (or one schema change) to `propose_write The result has a `state`: - `applied`: done. Tell the person what changed (table, row count). -- `held`: waiting for a person. **Give them the `review_url` from the result**, say in a sentence what the write changes and why, and stop. Don't wait in a loop; they may take a while. If they ask, check it later with `change_status`. +- `held`: waiting for a person. **Give them the `review_url` from the result as a Markdown link** (`[Review change 4]()`), so they can click it, say in a sentence what the write changes and why, and stop. Don't wait in a loop; they may take a while. If they ask, check it later with `change_status`. - `rejected`: refused by a rule or by a person, with a `reason`. Tell the person the reason. Don't try again in another form. - `failed`: Rowbit couldn't preview or apply it; the reason says why. Fix a genuine mistake (a typo, a wrong column) once; otherwise report it. - `approved` means a person approved it and Rowbit is applying it; `undone` means a person took an applied change back. From fe5852c91e88653bff72e151394540dbd3d5af2b Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 15:58:13 +0200 Subject: [PATCH 3/9] rowbit status and the review page's Setup view rowbit status lists every setup on the machine: the database each guards (never the password), whether its review page is up and how many changes are held, each agent's rules in brief and where it is connected. The review page says which database it guards in its header and gets a Setup view with the same for its own setup, from /api/setup. Signed-off-by: Ermag --- crates/rowbit-cli/src/main.rs | 35 +++++- crates/rowbit-cli/src/place.rs | 9 +- crates/rowbit-cli/src/status.rs | 145 ++++++++++++++++++++++ crates/rowbit-cli/tests/connect.rs | 12 ++ crates/rowbit-cli/tests/serve.rs | 2 +- crates/rowbit-engine/src/lib.rs | 8 ++ crates/rowbit-engine/src/overview.rs | 170 ++++++++++++++++++++++++++ crates/rowbit-mcp/tests/attacks.rs | 1 + crates/rowbit-web/dist/assets/app.css | 2 +- crates/rowbit-web/dist/assets/app.js | 12 +- crates/rowbit-web/src/api.rs | 22 ++++ crates/rowbit-web/src/lib.rs | 6 + crates/rowbit-web/tests/mcp_http.rs | 1 + crates/rowbit-web/tests/oidc.rs | 1 + crates/rowbit-web/tests/page.rs | 11 ++ docs/connect.md | 2 +- docs/quickstart.md | 4 +- docs/spec/setup.md | 19 +++ web/src/App.svelte | 15 ++- web/src/Setup.svelte | 93 ++++++++++++++ web/src/api.js | 1 + web/src/app.css | 8 ++ 22 files changed, 565 insertions(+), 14 deletions(-) create mode 100644 crates/rowbit-cli/src/status.rs create mode 100644 crates/rowbit-engine/src/overview.rs create mode 100644 web/src/Setup.svelte diff --git a/crates/rowbit-cli/src/main.rs b/crates/rowbit-cli/src/main.rs index ae7ebd0..9007ddd 100644 --- a/crates/rowbit-cli/src/main.rs +++ b/crates/rowbit-cli/src/main.rs @@ -4,6 +4,7 @@ mod grants; mod place; mod review; mod setup; +mod status; mod term; use std::path::PathBuf; @@ -198,6 +199,12 @@ enum Command { /// write, connects the agent clients you pick and sets the review /// page's password. Keeps its files in ~/.rowbit/. Setup(setup::SetupArgs), + /// What Rowbit is connected to: each setup's database, review page, + /// agents and where they're connected + /// + /// Lists every setup in ~/.rowbit and the demo, or with --dir (or in a + /// folder of Rowbit's) that one. Reads files only. + Status(status::StatusArgs), } /// Only Rowbit's state file: for commands that don't touch the database, @@ -393,7 +400,29 @@ async fn serve( engine.set_review_address(&address)?; let engine = std::sync::Arc::new(engine); tokio::spawn(rowbit_web::notify::Notifier::new(engine.clone(), config.notify.clone()).run()); + let setup_json = serde_json::json!({ + "database": database_url(&setup.url) + .ok() + .and_then(|u| rowbit_engine::overview::Database::from_url(&u)), + "dir": setup + .policy + .canonicalize() + .ok() + .and_then(|p| p.parent().map(place::tilde)), + "review_page": format!("{}/", address.trim_end_matches('/')), + "mcp": config.mcp_http.then(|| format!("{}/mcp", address.trim_end_matches('/'))), + "sign_in": match &sign_in { + rowbit_web::SignIn::Oidc(_) => format!( + "through {}", + config.oidc.as_ref().map(|o| o.issuer.as_str()).unwrap_or("an identity provider") + ), + _ => "page password, this machine only".to_owned(), + }, + "notify": config.notify.iter().map(|n| n.name.clone()).collect::>(), + "version": env!("CARGO_PKG_VERSION"), + }); let web = rowbit_web::Web { + setup: setup_json, sign_in, hosts: config.public_host().into_iter().collect(), secure: config.https(), @@ -486,7 +515,7 @@ fn place_files(command: &mut Command) -> anyhow::Result<()> { command: GrantsCommand::Diff { admin } | GrantsCommand::Apply { admin, .. }, } => admin.place()?, Command::Connect(args) => args.place(), - Command::Demo(_) | Command::Setup(_) | Command::Ping { .. } => {} + Command::Demo(_) | Command::Setup(_) | Command::Ping { .. } | Command::Status(_) => {} } Ok(()) } @@ -831,7 +860,8 @@ async fn main() -> anyhow::Result<()> { println!("{agent}"); if let Some((created, last)) = manual { println!( - " the agent's own token (rowbit agents token, or rowbit connect before 0.2.3): made {}, {}", + " {}: made {}, {}", + rowbit_engine::overview::OWN_TOKEN, review::when(created), used(last) ); @@ -889,6 +919,7 @@ async fn main() -> anyhow::Result<()> { connect::run(args)?; } Command::Setup(args) => setup::run(args).await?, + Command::Status(args) => status::run(args)?, Command::Ping { url } => { let adapter = rowbit_mysql::MySqlAdapter::new(&database_url(&url)?)?; let info = adapter.server_info().await?; diff --git a/crates/rowbit-cli/src/place.rs b/crates/rowbit-cli/src/place.rs index 7433aca..7002045 100644 --- a/crates/rowbit-cli/src/place.rs +++ b/crates/rowbit-cli/src/place.rs @@ -74,6 +74,8 @@ pub fn init(args: Vec) -> anyhow::Result> { { None } + // `status` lists every setup unless told which. + None if command.as_deref() == Some("status") => None, None => { let found = home().map(|h| setups(&h)).unwrap_or_default(); match pick(&found) { @@ -263,7 +265,12 @@ pub fn file(path: &mut PathBuf, default: &str) { /// `ROWBIT_DATABASE_URL` from the directory's `env` file, for when neither /// the flag nor the environment set it. pub fn database_url() -> anyhow::Result> { - let Some(dir) = dir() else { return Ok(None) }; + // Run inside a setup's or the demo's folder, that folder. + let dir = match dir() { + Some(dir) => dir, + None if Path::new("rowbit.yml").exists() => Path::new("."), + None => return Ok(None), + }; Ok(read_env_file(&dir.join(ENV_FILE))?.or_else(|| crate::demo::database_url(dir))) } diff --git a/crates/rowbit-cli/src/status.rs b/crates/rowbit-cli/src/status.rs new file mode 100644 index 0000000..eef1bbc --- /dev/null +++ b/crates/rowbit-cli/src/status.rs @@ -0,0 +1,145 @@ +//! `rowbit status`: every setup on this machine, what database each +//! guards, whether its review page is up, and where its agents are +//! connected. Reads files only; never connects to a database. See "What +//! Rowbit is connected to" in docs/spec/setup.md. + +use std::path::{Path, PathBuf}; + +use rowbit_engine::overview::{self, Database}; + +use crate::place::{self, Found}; + +#[derive(clap::Args)] +pub struct StatusArgs {} + +pub fn run(_: StatusArgs) -> anyhow::Result<()> { + let found = setups()?; + if found.is_empty() { + println!("No Rowbit setup on this machine yet. Set one up with: rowbit setup"); + return Ok(()); + } + let width = found.iter().map(|f| f.name.len()).max().unwrap_or(0); + for (i, f) in found.iter().enumerate() { + if i > 0 { + println!(); + } + for (j, line) in describe(f).iter().enumerate() { + let name = if j == 0 { f.name.as_str() } else { "" }; + println!("{name: anyhow::Result> { + let here = match place::dir() { + Some(d) => Some(d.to_owned()), + None if Path::new("rowbit.yml").exists() => Some(std::env::current_dir()?), + None => None, + }; + if let Some(dir) = here { + let dir = dir.canonicalize().unwrap_or(dir); + let name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("here") + .to_owned(); + return Ok(vec![Found { name, dir }]); + } + Ok(place::home().map(|h| place::setups(&h)).unwrap_or_default()) +} + +/// The lines `rowbit status` prints for one setup. +fn describe(f: &Found) -> Vec { + let dir = &f.dir; + let mut lines = vec![place::tilde(dir)]; + lines.push(match database_url(dir) { + Ok(Some(url)) => match Database::from_url(&url) { + Some(db) => format!("database: {}", db.line()), + None => "database: the URL in the env file doesn't parse".into(), + }, + Ok(None) => "database: no URL (no env file)".into(), + Err(e) => format!("database: {e:#}"), + }); + let address = server_address(dir); + let up = std::net::TcpStream::connect_timeout( + &address.listen, + std::time::Duration::from_millis(300), + ) + .is_ok(); + let held = held(dir); + lines.push(format!( + "review page: {}, {}{}", + crate::term::link(&format!("{}/", address.base().trim_end_matches('/'))), + if up { "running" } else { "not running" }, + match held { + Some(0) => ", nothing held".to_owned(), + Some(n) => format!(", {n} held"), + None => String::new(), + } + )); + match agents(dir) { + Ok(agents) => { + for a in agents { + lines.push(format!("agent {}: {}", a.id, a.rules_line())); + if a.connections.is_empty() { + lines.push( + " not connected over HTTP; `rowbit connect ` connects it".into(), + ); + } + for c in a.connections { + lines.push(format!( + " {} ({})", + c.label, + c.used_ms + .map(|u| format!("last used {}", crate::review::when(u))) + .unwrap_or_else(|| "never used".into()) + )); + } + } + } + Err(e) => lines.push(format!("agents: {e:#}")), + } + lines +} + +fn database_url(dir: &Path) -> anyhow::Result> { + Ok( + place::read_env_file(&dir.join(place::ENV_FILE))? + .or_else(|| crate::demo::database_url(dir)), + ) +} + +fn server_address(dir: &Path) -> rowbit_web::config::Address { + std::fs::read_to_string(dir.join("rowbit-server.yml")) + .ok() + .and_then(|src| rowbit_web::config::ServerConfig::address(&src).ok()) + .unwrap_or_default() +} + +fn store(dir: &Path) -> Option { + let path: PathBuf = dir.join("rowbit.db"); + path.exists() + .then(|| rowbit_store::Store::open(&path).ok()) + .flatten() +} + +fn held(dir: &Path) -> Option { + let store = store(dir)?; + store + .list(rowbit_core::change::State::Held) + .ok() + .map(|h| h.len()) +} + +fn agents(dir: &Path) -> anyhow::Result> { + let src = std::fs::read_to_string(dir.join("rowbit.yml"))?; + let policy = rowbit_policy::PolicyFile::from_yaml(&src)?; + let store = match store(dir) { + Some(s) => s, + None => rowbit_store::Store::open_in_memory()?, + }; + Ok(overview::agents(&policy, &store)?) +} diff --git a/crates/rowbit-cli/tests/connect.rs b/crates/rowbit-cli/tests/connect.rs index e77595a..6a57d0b 100644 --- a/crates/rowbit-cli/tests/connect.rs +++ b/crates/rowbit-cli/tests/connect.rs @@ -321,6 +321,18 @@ fn a_project_folder_finds_the_setup_in_rowbit_home() { ); let (_, agents) = in_app(&["agents"]); assert!(agents.contains("Claude Code in "), "{agents}"); + // `rowbit status` says what it guards, without the password. + let (ok, status) = in_app(&["status"]); + assert!(ok, "{status}"); + assert!( + status.contains("shop ~/.rowbit/shop") + && status.contains("database: shop on h:3306 as u, no TLS") + && status.contains("not running") + && status.contains("agent coding-agent: ") + && status.contains(" Claude Code in "), + "{status}" + ); + assert!(!status.contains(":p@"), "{status}"); // Two, and neither the default: Rowbit asks which. std::fs::create_dir_all(c.home.join(".rowbit/other")).unwrap(); diff --git a/crates/rowbit-cli/tests/serve.rs b/crates/rowbit-cli/tests/serve.rs index a0f90eb..fecdcfc 100644 --- a/crates/rowbit-cli/tests/serve.rs +++ b/crates/rowbit-cli/tests/serve.rs @@ -75,7 +75,7 @@ fn agent_tokens_are_shown_once_and_revocable() { assert_eq!(token.len(), 47); let (ok, out) = rowbit(&["agents"], &d); assert!( - ok && out.contains("refund-bot\n the agent's own token") && out.contains("never used"), + ok && out.contains("refund-bot\n single token") && out.contains("never used"), "{out}" ); assert!(!out.contains(&token)); diff --git a/crates/rowbit-engine/src/lib.rs b/crates/rowbit-engine/src/lib.rs index a2976f9..b14d2be 100644 --- a/crates/rowbit-engine/src/lib.rs +++ b/crates/rowbit-engine/src/lib.rs @@ -26,6 +26,8 @@ use rowbit_policy::{ }; use rowbit_store::{AuditEvent, Change, ChangeId, NewDelivery, Review, Store, StoreError}; pub use rowbit_store::{Delivery, DeliveryOutcome}; + +pub mod overview; use thiserror::Error; #[derive(Debug, Error)] @@ -558,6 +560,12 @@ impl Engine { Ok(agent.filter(|a| self.policy.agents.contains_key(a))) } + /// Each agent in `rowbit.yml`, its rules in brief and where it is + /// connected. + pub fn agents_overview(&self) -> Result, EngineError> { + Ok(overview::agents(&self.policy, &self.store())?) + } + /// Checks the whole audit log; see [`Store::verify`]. pub fn verify(&self) -> Result, EngineError> { Ok(self.store().verify()?) diff --git a/crates/rowbit-engine/src/overview.rs b/crates/rowbit-engine/src/overview.rs new file mode 100644 index 0000000..1a3408a --- /dev/null +++ b/crates/rowbit-engine/src/overview.rs @@ -0,0 +1,170 @@ +//! What a setup is connected to, for `rowbit status` and the review page's +//! Setup view: the database without its password, each agent's rules in +//! brief, and where each agent is connected. See "What Rowbit is +//! connected to" in docs/spec/setup.md. + +use rowbit_policy::{Decision, Mode, PolicyFile}; +use rowbit_store::{Store, StoreError}; +use serde::Serialize; + +/// The database a URL names. Never the password. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Database { + pub host: String, + pub port: u16, + pub database: String, + pub user: String, + /// "no TLS", "TLS, certificate and host name checked", ... + pub tls: String, +} + +impl Database { + pub fn from_url(url: &str) -> Option { + let opts = rowbit_mysql::url::opts(url).ok()?; + Some(Database { + host: opts.ip_or_hostname().to_owned(), + port: opts.tcp_port(), + database: opts.db_name().unwrap_or_default().to_owned(), + user: opts.user().unwrap_or_default().to_owned(), + tls: rowbit_mysql::url::describe(&opts).to_owned(), + }) + } + + /// "shop on db.internal:3306 as rowbit, TLS ..." + pub fn line(&self) -> String { + format!( + "{} on {}:{} as {}, {}", + self.database, self.host, self.port, self.user, self.tls + ) + } +} + +/// One agent in `rowbit.yml`, and where it is connected. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Agent { + pub id: String, + pub description: Option, + /// "enforce" or "shadow". + pub mode: &'static str, + /// What happens to a write no rule matches: "allow", "hold", "block". + pub default: &'static str, + pub rules: usize, + pub scoped: bool, + pub connections: Vec, +} + +impl Agent { + /// "holds every write", "3 rules, else holds", ... + pub fn rules_line(&self) -> String { + let default = match self.default { + "allow" => "allows", + "block" => "refuses", + _ => "holds", + }; + let mut s = match self.rules { + 0 => format!("{default} every write"), + 1 => format!("1 rule, else {default}"), + n => format!("{n} rules, else {default}"), + }; + if self.mode == "shadow" { + s.push_str(" (shadow mode: logged, not enforced)"); + } + if self.scoped { + s.push_str(", scoped"); + } + s + } +} + +/// A token an agent signs in with over HTTP: one per client entry +/// `rowbit connect` made, and the one `rowbit agents token` made. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Connection { + /// "Claude Code in ~/projects/app" + pub label: String, + pub created_ms: i64, + pub used_ms: Option, +} + +/// The label of the token `rowbit agents token` makes. +pub const OWN_TOKEN: &str = "single token (rowbit agents token, or rowbit connect before 0.2.3)"; + +pub fn agents(policy: &PolicyFile, store: &Store) -> Result, StoreError> { + let tokens = store.connection_tokens()?; + let mut out = Vec::new(); + for (id, a) in &policy.agents { + let mut connections = Vec::new(); + if let Some((created_ms, used_ms)) = store.agent_token_times(id)? { + connections.push(Connection { + label: OWN_TOKEN.into(), + created_ms, + used_ms, + }); + } + connections.extend( + tokens + .iter() + .filter(|t| &t.agent == id) + .map(|t| Connection { + label: t.label.clone(), + created_ms: t.created_ms, + used_ms: t.used_ms, + }), + ); + out.push(Agent { + id: id.clone(), + description: a.description.clone(), + mode: match a.mode { + Mode::Enforce => "enforce", + Mode::Shadow => "shadow", + }, + default: match a.default { + Decision::Allow => "allow", + Decision::Hold => "hold", + Decision::Block => "block", + }, + rules: a.rules.len(), + scoped: a.scopes.is_some(), + connections, + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_database_comes_without_its_password() { + let d = Database::from_url( + "mysql://rowbit:s3cret@db.internal:3307/shop?ssl-mode=VERIFY_IDENTITY", + ) + .unwrap(); + assert_eq!( + d.line(), + "shop on db.internal:3307 as rowbit, TLS, certificate and host name checked" + ); + assert!(!format!("{d:?}").contains("s3cret")); + } + + #[test] + fn agents_list_their_rules_and_connections() { + let policy = PolicyFile::from_yaml( + "version: 0\nagents:\n bot:\n default: hold\n ops:\n mode: shadow\n default: block\n rules:\n - name: no deletes\n when: { statement: delete }\n then: block\n", + ) + .unwrap(); + let mut store = Store::open_in_memory().unwrap(); + store + .issue_connection_token("bot", "Claude Code in ~/app") + .unwrap(); + let agents = agents(&policy, &store).unwrap(); + assert_eq!(agents[0].rules_line(), "holds every write"); + assert_eq!(agents[0].connections[0].label, "Claude Code in ~/app"); + assert_eq!( + agents[1].rules_line(), + "1 rule, else refuses (shadow mode: logged, not enforced)" + ); + assert!(agents[1].connections.is_empty()); + } +} diff --git a/crates/rowbit-mcp/tests/attacks.rs b/crates/rowbit-mcp/tests/attacks.rs index 4cf8c39..2e0a72d 100644 --- a/crates/rowbit-mcp/tests/attacks.rs +++ b/crates/rowbit-mcp/tests/attacks.rs @@ -478,6 +478,7 @@ async fn approving_through_the_review_page(var: &str, table: &str) { let page = rowbit_web::router( w.engine.clone(), rowbit_web::Web { + setup: serde_json::Value::Null, sign_in: rowbit_web::SignIn::Password { hash: rowbit_web::hash_password(&rowbit_web::new_password()), }, diff --git a/crates/rowbit-web/dist/assets/app.css b/crates/rowbit-web/dist/assets/app.css index c4db110..1e2c9f6 100644 --- a/crates/rowbit-web/dist/assets/app.css +++ b/crates/rowbit-web/dist/assets/app.css @@ -1,2 +1,2 @@ -:root{--bg:#f4f6fa;--surface:#fff;--ink:#141a2b;--muted:#59627a;--line:#d9deea;--accent:#2b47c9;--before:#a03030;--after:#17794a;--held:#a86300;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}@media (prefers-color-scheme:dark){:root{--bg:#0d111c;--surface:#151b2b;--ink:#e6e9f2;--muted:#9aa3bb;--line:#283149;--accent:#8ea2ff;--before:#ff8a8a;--after:#5fd39a;--held:#f0b04a;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0;font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}a{color:var(--accent)}header{border-bottom:1px solid var(--line);background:var(--surface);flex-wrap:wrap;align-items:center;gap:20px;padding:10px 16px;display:flex}header .brand{color:var(--ink);font-weight:700;text-decoration:none}header nav{gap:14px;display:flex}header nav a{color:var(--muted);text-decoration:none}header nav a.active{color:var(--ink);font-weight:600}header .who{color:var(--muted);margin-left:auto}main{max-width:1100px;margin:0 auto;padding:16px}h1{margin:8px 0 12px;font-size:1.4rem}h2{margin:24px 0 8px;font-size:1.1rem}section,.card{background:var(--surface);border:1px solid var(--line);border-radius:8px;margin:12px 0;padding:14px 16px;display:block}.card{color:inherit;text-decoration:none}.card:hover,.card:focus-visible{border-color:var(--accent)}.card-top{flex-wrap:wrap;align-items:baseline;gap:12px;display:flex}.card-top h1{margin:0}pre{white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:4px;padding:8px 10px}code{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:.9em}.scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;margin:8px 0}th,td{text-align:left;border-bottom:1px solid var(--line);vertical-align:top;padding:4px 8px}td{white-space:pre-wrap;word-break:break-word}tr.first td{border-top:2px solid var(--line)}td.before{color:var(--before)}td.after{color:var(--after)}td.same{color:var(--muted)}.nowrap{white-space:nowrap}i.null{color:var(--muted)}.binary,.number{font-family:ui-monospace,SF Mono,Menlo,monospace}.muted{color:var(--muted)}.small{font-size:.85rem}.error{color:var(--before)}.ok{color:var(--after)}.actions{flex-wrap:wrap;align-items:end;gap:16px;margin-top:12px;display:flex}.actions form,.filters{flex-wrap:wrap;align-items:end;gap:8px;display:flex}label{color:var(--muted);flex-direction:column;gap:2px;font-size:.85rem;display:flex}label.inline{flex-direction:row;gap:6px;margin-left:12px;display:inline-flex}input,select,button{font:inherit;border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:4px;padding:4px 10px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button.approve{background:var(--accent);color:#fff;border-color:var(--accent)}button.link{color:var(--accent);background:0 0;border:0;padding:0}.state{background:var(--bg);border-radius:3px;padding:1px 7px;font-family:ui-monospace,monospace;font-size:.8rem}.state.held{color:var(--held)}.state.applied,.state.approved{color:var(--after)}.state.rejected,.state.failed{color:var(--before)}.pager{align-items:center;gap:12px;display:flex}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.shadow{color:var(--held)} +:root{--bg:#f4f6fa;--surface:#fff;--ink:#141a2b;--muted:#59627a;--line:#d9deea;--accent:#2b47c9;--before:#a03030;--after:#17794a;--held:#a86300;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}@media (prefers-color-scheme:dark){:root{--bg:#0d111c;--surface:#151b2b;--ink:#e6e9f2;--muted:#9aa3bb;--line:#283149;--accent:#8ea2ff;--before:#ff8a8a;--after:#5fd39a;--held:#f0b04a;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0;font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}a{color:var(--accent)}header{border-bottom:1px solid var(--line);background:var(--surface);flex-wrap:wrap;align-items:center;gap:20px;padding:10px 16px;display:flex}header .brand{color:var(--ink);font-weight:700;text-decoration:none}header nav{gap:14px;display:flex}header nav a{color:var(--muted);text-decoration:none}header nav a.active{color:var(--ink);font-weight:600}header .who{color:var(--muted);margin-left:auto}main{max-width:1100px;margin:0 auto;padding:16px}h1{margin:8px 0 12px;font-size:1.4rem}h2{margin:24px 0 8px;font-size:1.1rem}section,.card{background:var(--surface);border:1px solid var(--line);border-radius:8px;margin:12px 0;padding:14px 16px;display:block}.card{color:inherit;text-decoration:none}.card:hover,.card:focus-visible{border-color:var(--accent)}.card-top{flex-wrap:wrap;align-items:baseline;gap:12px;display:flex}.card-top h1{margin:0}pre{white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:4px;padding:8px 10px}code{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:.9em}.scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;margin:8px 0}th,td{text-align:left;border-bottom:1px solid var(--line);vertical-align:top;padding:4px 8px}td{white-space:pre-wrap;word-break:break-word}tr.first td{border-top:2px solid var(--line)}td.before{color:var(--before)}td.after{color:var(--after)}td.same{color:var(--muted)}.nowrap{white-space:nowrap}i.null{color:var(--muted)}.binary,.number{font-family:ui-monospace,SF Mono,Menlo,monospace}.muted{color:var(--muted)}.small{font-size:.85rem}.error{color:var(--before)}.ok{color:var(--after)}.actions{flex-wrap:wrap;align-items:end;gap:16px;margin-top:12px;display:flex}.actions form,.filters{flex-wrap:wrap;align-items:end;gap:8px;display:flex}label{color:var(--muted);flex-direction:column;gap:2px;font-size:.85rem;display:flex}label.inline{flex-direction:row;gap:6px;margin-left:12px;display:inline-flex}input,select,button{font:inherit;border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:4px;padding:4px 10px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button.approve{background:var(--accent);color:#fff;border-color:var(--accent)}button.link{color:var(--accent);background:0 0;border:0;padding:0}.state{background:var(--bg);border-radius:3px;padding:1px 7px;font-family:ui-monospace,monospace;font-size:.8rem}.state.held{color:var(--held)}.state.applied,.state.approved{color:var(--after)}.state.rejected,.state.failed{color:var(--before)}.pager{align-items:center;gap:12px;display:flex}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.shadow{color:var(--held)}header .guarding{color:var(--muted);font-size:.9rem;text-decoration:none}header .guarding:hover,header .guarding code{color:var(--ink)}.facts{grid-template-columns:max-content 1fr;gap:4px 16px;margin:0;display:grid}.facts dt{color:var(--muted)}.facts dd{overflow-wrap:anywhere;margin:0}h3{margin:16px 0 4px;font-size:1rem} /*$vite$:1*/ \ No newline at end of file diff --git a/crates/rowbit-web/dist/assets/app.js b/crates/rowbit-web/dist/assets/app.js index 4fb2e41..e82d047 100644 --- a/crates/rowbit-web/dist/assets/app.js +++ b/crates/rowbit-web/dist/assets/app.js @@ -1,6 +1,6 @@ -var e=Array.isArray,t=Array.prototype.indexOf,n=Array.prototype.includes,r=Array.from,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyDescriptors,s=Object.prototype,c=Array.prototype,l=Object.getPrototypeOf,u=Object.isExtensible,d=()=>{};function f(e){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}function m(e,t){if(Array.isArray(e))return e;if(t===void 0||!(Symbol.iterator in e))return Array.from(e);let n=[];for(let r of e)if(n.push(r),n.length===t)break;return n}var h=1024,g=2048,_=4096,v=8192,y=16384,b=32768,x=1<<25,S=65536,ee=1<<19,te=1<<20,ne=1<<25,re=1<<21,ie=1<<22,ae=1<<23,oe=Symbol(`$state`),se=Symbol(`component`),ce=Symbol(``),le=Symbol(`attributes`),ue=Symbol(`class`),de=Symbol(`style`),fe=Symbol(`text`),pe=Symbol(`form reset`),me=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"},he=!!globalThis.document?.contentType&&globalThis.document.contentType.includes(`xml`),ge={},C=Symbol(`uninitialized`),_e=`http://www.w3.org/1999/xhtml`;function ve(){console.warn(`https://svelte.dev/e/derived_inert`)}function ye(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}function be(){console.warn(`https://svelte.dev/e/select_multiple_invalid_value`)}function xe(){console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`)}var w=!1;function Se(e){w=e}var T;function E(e){if(e===null)throw ye(),ge;return T=e}function Ce(){return E(en(T))}function D(e){if(w){if(en(T)!==null)throw ye(),ge;T=e}}function we(e=1){if(w){for(var t=e,n=T;t--;)n=en(n);T=n}}function Te(e=!0){for(var t=0,n=T;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else(r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=en(n);e&&n.remove(),n=i}}function Ee(e){if(!e||e.nodeType!==8)throw ye(),ge;return e.data}function De(e){return e===this.v}function Oe(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function ke(e){return!Oe(e,this.v)}function Ae(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function je(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function Me(e,t,n){throw Error(`https://svelte.dev/e/each_key_duplicate`)}function Ne(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function Pe(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function Fe(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function Ie(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function Le(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function Re(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function ze(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function Be(){throw Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`)}var O=null;function Ve(e){O=e}function He(e,t=!1,n){O={p:O,i:!1,c:null,e:null,s:e,x:null,r:G,l:null}}function Ue(e){var t=O,n=t.e;if(n!==null){t.e=null;for(var r of n)mn(r)}return e!==void 0&&(t.x=e),t.i=!0,O=t.p,We(e)}function We(e={}){return i(e,se,{value:!0}),e}function Ge(){return!0}var Ke=[];function qe(){var e=Ke;Ke=[],f(e)}function Je(e){if(Ke.length===0&&!bt){var t=Ke;queueMicrotask(()=>{t===Ke&&qe()})}Ke.push(e)}function Ye(){for(;Ke.length>0;)qe()}var Xe=~(g|_|h);function k(e,t){e.f=e.f&Xe|t}function Ze(e){e.f&512||e.deps===null?k(e,h):k(e,_)}function Qe(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),k(e,h)}var $e=!1;function et(){$e||($e=!0,document.addEventListener(`reset`,e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(let t of e.target.elements)t[pe]?.()})},{capture:!0}))}function tt(e){var t=H,n=G;W(null),Pn(null);try{return e()}finally{W(t),Pn(n)}}function nt(e,t,n,r=n){e.addEventListener(t,()=>tt(n));let i=e[pe];e[pe]=i?()=>{i(),r(!0)}:()=>r(!0),et()}function rt(e,t,n,r){let i=Ge()?st:dt;var a=e.filter(e=>!e.settled),o=t.map(i);if(n.length===0&&a.length===0){r(o);return}var s=G,c=it(),l=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function u(e){if(!(s.f&16384)){c();try{r([...o,...e])}catch(e){sn(e,s)}at()}}var d=ot();if(n.length===0){l.then(()=>u([])).finally(d);return}function f(){Promise.all(n.map(e=>lt(e))).then(u).catch(e=>sn(e,s)).finally(d)}l?l.then(()=>{c(),f(),at()}):f()}function it(){var e=G,t=H,n=O,r=A;return function(i=!0){Pn(e),W(t),Ve(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function at(e=!0){Pn(null),W(null),Ve(null),e&&A?.deactivate()}function ot(){var e=G,t=e.b,n=A,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function st(e){var t=2|g;return G!==null&&(G.f|=ee),{ctx:O,deps:null,effects:null,equals:De,f:t,fn:e,reactions:null,rv:0,v:C,wv:0,parent:G,ac:null}}var ct=Symbol(`obsolete`);function lt(e,t,n){let r=G;r===null&&je();var i=void 0,a=Lt(C),o=!H,s=new Set;return _n(()=>{var t=G,n=p();i=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==me&&n.reject(e)}).finally(at)}catch(e){n.reject(e),at()}var c=A;if(o){if(t.f&32768)var l=ot();if(r.b?.is_rendered())c.async_deriveds.get(t)?.reject(ct);else for(let e of s.values())e.reject(ct);s.add(n),c.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),s.delete(n),t!==ct&&(c.activate(),t?(a.f|=ae,Vt(a,t)):(a.f&8388608&&(a.f^=ae),Vt(a,e)),c.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),fn(()=>{for(let e of s)e.reject(ct)}),new Promise(e=>{function t(n){function r(){n===i?e(a):t(i)}n.then(r,r)}t(i)})}function ut(e){let t=st(e);return In(t),t}function dt(e){let t=st(e);return t.equals=ke,t}function ft(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(me),t.ac=null}),t.fn!==null&&(t.teardown=d),Jn(t,0),xn(t))}function gt(e){if(e.effects!==null)for(let t of e.effects)t.teardown&&t.fn!==null&&Yn(t)}var _t=null,A=null,vt=null,j=null,yt=null,bt=!1,xt=!1,St=null,Ct=null,wt=0,Tt=1,Et=class e{id=Tt++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){_t===null?_t=this:(_t.#n=this,this.#t=_t),_t=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)k(r,g),t(r);for(r of n.m)k(r,_),t(r)}this.#p.add(e)}#g(){var e=[];for(let i of this.#c)if(!(i.f&16384||!(i.f&6144))){for(var t=i,n=!1;t.parent!==null;){t=t.parent;var r=t.f;if(r&96){if(!(r&1024)){n=!0;break}t.f^=h}}n||e.push(t)}return this.#c=[],e}#_(){this.#e=!0;for(let e of this.#u)this.#d.delete(e),k(e,g),this.schedule(e);for(let e of this.#d)k(e,_),this.schedule(e);this.apply();for(var t=St=[],n=[],r=Ct=[];this.#c.length>0;){wt++>1e3&&(this.#S(),Ot());for(let e of this.#g())try{this.#v(e,t,n)}catch(t){throw Nt(e),this.#h()||this.discard(),t}}if(A=null,r.length>0){var i=e.ensure();for(let e of r)i.schedule(e)}if(St=null,Ct=null,this.#h()){this.#x(n),this.#x(t);for(let[e,t]of this.#f)Mt(e,t);r.length>0&&A.#_();return}let a=this.#y();if(a)this.#x(n),this.#x(t),a.#b(this);else{this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),vt=this,At(n),At(t),vt=null,this.#s?.resolve();var o=A;if(this.#a===0&&(this.#c.length===0||o!==null)&&this.#S(),this.#c.length>0){if(o!==null){for(let e of this.#c)o.#c.push(e);this.#c=[]}else o=this}o!==null&&(Ft.clear(),o.#_())}}#v(e,t,n){e.f^=h;for(var r=e.first;r!==null;){var i=r.f,a=!!(i&96);if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=h:i&4?t.push(r):Un(r)&&(i&16&&this.#d.add(r),Yn(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#y(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#b(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}e.async_deriveds.clear(),this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null&&!(e.f&2&&!(e.f&6144)))for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),k(i,g),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#S(),A=this,this.#_()}#x(e){for(var t=0;t{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=p()).promise}static ensure(){if(A===null){let t=A=new e;!xt&&!bt&&Je(()=>{t.#e||t.flush()})}return A}apply(){j=null}schedule(e){yt=e,e.b?.is_pending&&e.f&16777228&&!(e.f&32768)?e.b.defer_effect(e):this.#c.push(e)}#S(){if(this.linked){var e=this.#t,t=this.#n;e===null||(e.#n=t),t===null?_t=e:t.#t=e,this.linked=!1}}};function Dt(e){var t=bt;bt=!0;try{var n;for(e&&(A!==null&&!A.is_fork&&A.flush(),n=e());;){if(Ye(),A===null)return n;A.flush()}}finally{bt=t}}function Ot(){try{Ie()}catch(e){sn(e,yt)}}var kt=null;function At(e){var t=e.length;if(t!==0){for(var n=0;n0)){Ft.clear();for(let e of kt){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)kt.has(n)&&(kt.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||Yn(n)}}kt.clear()}}kt=null}}function jt(e){A.schedule(e)}function Mt(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),k(e,h);for(var n=e.first;n!==null;)Mt(n,t),n=n.next}}function Nt(e){k(e,h);for(var t=e.first;t!==null;)Nt(t),t=t.next}var Pt=new Set,Ft=new Map,It=!1;function Lt(e,t){return{f:0,v:e,reactions:null,equals:De,rv:0,wv:0}}function M(e,t){let n=Lt(e,t);return In(n),n}function Rt(e,t=!1,n=!0){let r=Lt(e);return t||(r.equals=ke),r}function N(e,t,n=!1){return H!==null&&(!U||H.f&131072)&&Ge()&&H.f&4325394&&(Fn===null||!Fn.has(e))&&ze(),Vt(e,n?Gt(t):t,Ct)}var zt=null,Bt=0;function Vt(e,t,n=null){if(!e.equals(t)){Mn?Ft.set(e,t):Ft.has(e)||Ft.set(e,e.v);var r=Et.ensure();if(r.capture(e,t),e.f&2){let t=e;e.f&2048&&pt(t),j===null&&Ze(t)}e.wv=Hn(),zt=null,Bt=0,Wt(e,g,n),zt=null,Ge()&&G!==null&&G.f&1024&&!(G.f&96)&&(J===null?Ln([e]):J.push(e)),!r.is_fork&&Pt.size>0&&!It&&Ht()}return t}function Ht(){It=!1;for(let e of Pt){e.f&1024&&k(e,_);let t;try{t=Un(e)}catch{t=!0}t&&Yn(e)}Pt.clear()}function Ut(e){N(e,e.v+1)}function Wt(e,t,n){var r=e.reactions;if(r!==null){var i=Ge(),a=r.length;if(Bt+=a,Bt>1e5&&zt===null&&(zt=new Set),zt!==null){if(zt.has(e))return;zt.add(e)}for(var o=0;o{if(Bn===d)return e();var t=H,n=Bn;W(null),Vn(d);var r=e();return W(t),Vn(n),r};return i&&r.set(`length`,M(t.length,u)),new Proxy(t,{defineProperty(e,t,n){`value`in n&&n.configurable!==!1&&n.enumerable!==!1&&n.writable!==!1||Le();var i=r.get(t);return i===void 0?f(()=>{var e=M(n.value,u);return r.set(t,e),e}):N(i,n.value,!0),!0},deleteProperty(e,t){var n=r.get(t);if(n===void 0){if(t in e){let e=f(()=>M(C,u));r.set(t,e),Ut(o)}}else N(n,C),Ut(o);return!0},get(e,n,i){if(n===oe)return t;var o=r.get(n),s=n in e;if(o===void 0&&(!s||a(e,n)?.writable)&&(o=f(()=>M(Gt(s?e[n]:C),u)),r.set(n,o)),o!==void 0){var c=Y(o);return c===C?void 0:c}return Reflect.get(e,n,i)},getOwnPropertyDescriptor(e,t){this.has?.(e,t);var n=Reflect.getOwnPropertyDescriptor(e,t),i=r.get(t);if(i!==void 0){var a=Y(i);if(a===C)return;if(n&&`value`in n)n.value=a;else return{enumerable:!0,configurable:!0,value:a,writable:!0}}return n},has(e,t){if(t===oe)return!0;var n=r.get(t),i=n!==void 0&&n.v!==C||Reflect.has(e,t);return(n!==void 0||G!==null&&(!i||a(e,t)?.writable))&&(n===void 0&&(n=f(()=>M(i?Gt(e[t]):C,u)),r.set(t,n)),Y(n)===C)?!1:i},set(e,t,n,s){var c=r.get(t),l=t in e;if(i&&t===`length`)for(var d=n;dM(C,u)),r.set(d+``,p)):N(p,C)}if(c===void 0)(!l||a(e,t)?.writable)&&(c=f(()=>M(void 0,u)),N(c,Gt(n)),r.set(t,c));else{l=c.v!==C;var m=f(()=>Gt(n));N(c,m)}var h=Reflect.getOwnPropertyDescriptor(e,t);if(h?.set&&h.set.call(s,n),!l){if(i&&typeof t==`string`){var g=r.get(`length`),_=Number(t);Number.isInteger(_)&&_>=g.v&&N(g,_+1)}Ut(o)}return!0},ownKeys(e){Y(o);var t=Reflect.ownKeys(e).filter(e=>{var t=r.get(e);return t===void 0||t.v!==C});for(var[n,i]of r)i.v!==C&&!(n in e)&&t.push(n);return t},setPrototypeOf(){Re()}})}function Kt(e){try{if(typeof e==`object`&&e&&oe in e)return e[oe]}catch{}return e}function qt(e,t){return Object.is(Kt(e),Kt(t))}var Jt,Yt,Xt,Zt;function Qt(){if(Jt===void 0){Jt=window,Yt=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;Xt=a(t,`firstChild`).get,Zt=a(t,`nextSibling`).get,u(e)&&(e[ue]=void 0,e[le]=null,e[de]=void 0,e.__e=void 0),u(n)&&(n[fe]=void 0)}}function P(e=``){return document.createTextNode(e)}function $t(e){return Xt.call(e)}function en(e){return Zt.call(e)}function F(e,t){if(!w)return $t(e);var n=$t(T);if(n===null)n=T.appendChild(P());else if(t&&n.nodeType!==3){var r=P();return n?.before(r),E(r),r}return t&&an(n),E(n),n}function I(e,t=!1){if(!w){var n=$t(e);return n instanceof Comment&&n.data===``?en(n):n}if(t){if(T?.nodeType!==3){var r=P();return T?.before(r),E(r),r}an(T)}return T}function L(e,t=!1){if(!w)return $t(e);var n=F(e,t);return D(e),n}function R(e,t=1,n=!1){let r=w?T:e;for(var i;t--;)i=r,r=en(r);if(!w)return r;if(n){if(r?.nodeType!==3){var a=P();return r===null?i?.after(a):r.before(a),E(a),a}an(r)}return E(r),r}function tn(e){e.textContent=``}function nn(){return!1}function rn(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function an(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function on(e){var t=G;if(t===null)return H.f|=ae,e;if(!(t.f&32768)&&!(t.f&4))throw e;sn(e,t)}function sn(e,t){if(!(t!==null&&t.f&16384)){for(;t!==null;){if(t.f&128&&!(t.f&33570816)){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}}function cn(e){G===null&&(H===null&&Fe(e),Pe()),Mn&&Ne(e)}function ln(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function un(e,t){var n=G;n!==null&&n.f&8192&&(e|=v);var r={ctx:O,deps:null,nodes:null,f:e|g|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};A?.register_created_effect(r);var i=r;if(e&4)St===null?Et.ensure().schedule(r):St.push(r);else if(t!==null){try{Yn(r)}catch(e){throw V(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=S))}if(i!==null&&(i.parent=n,n!==null&&ln(i,n),H!==null&&H.f&2&&!(e&64))){var a=H;(a.effects??=[]).push(i)}return r}function dn(){return H!==null&&!U}function fn(e){let t=un(8,null);return k(t,h),t.teardown=e,t}function pn(e){cn(`$effect`);var t=G.f;if(!H&&t&32&&O!==null&&!O.i){var n=O;(n.e??=[]).push(e)}else return mn(e)}function mn(e){return un(4|te,e)}function hn(e){Et.ensure();let t=un(64|ee,e);return(e={})=>new Promise(n=>{e.outro?Tn(t,()=>{V(t),n(void 0)}):(V(t),n(void 0))})}function gn(e){return un(4,e)}function _n(e){return un(ie|ee,e)}function vn(e,t=0){return un(8|t,e)}function z(e,t=[],n=[],r=[]){rt(r,t,n,t=>{un(8,()=>{e(...t.map(Y))})})}function yn(e,t=0){return un(16|t,e)}function B(e){return un(32|ee,e)}function bn(e){var t=e.teardown;if(t!==null){let n=Mn,r=H;Nn(!0),W(null);try{t.call(null)}catch(t){sn(t,e.parent)}finally{Nn(n),W(r)}}}function xn(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&&tt(()=>{e.abort(me)});var r=n.next;n.f&64?n.parent=null:V(n,t),n=r}}function Sn(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||V(t),t=n}}function V(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(Cn(e.nodes.start,e.nodes.end),n=!0),e.f|=x,xn(e,t&&!n),Jn(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();bn(e),e.f^=x,e.f|=y;var i=e.parent;i!==null&&i.first!==null&&wn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Cn(e,t){for(;e!==null;){var n=e===t?null:en(e);e.remove(),e=n}}function wn(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Tn(e,t,n=!0){var r=[];e.f|=256,En(e,r,!0);var i=()=>{n&&V(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function En(e,t,n){if(!(e.f&8192)){e.f^=v;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=!!(i.f&65536)||!!(i.f&32)&&!!(e.f&16);En(i,t,o?n:!1)}i=a}}}function Dn(e){e.f&=-257,On(e,!0)}function On(e,t){if(!(e.f&256)&&e.f&8192){e.f^=v,e.f&1024||(k(e,g),Et.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=!!(n.f&65536)||!!(n.f&32);On(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function kn(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:en(n);t.append(n),n=i}}var An=null,jn=!1,Mn=!1;function Nn(e){Mn=e}var H=null,U=!1;function W(e){H=e}var G=null;function Pn(e){G=e}var Fn=null;function In(e){H!==null&&(H.f&2097152||H.f&2)&&(Fn??=new Set).add(e)}var K=null,q=0,J=null;function Ln(e){J=e}var Rn=1,zn=0,Bn=zn;function Vn(e){Bn=e}function Hn(){return++Rn}function Un(e){var t=e.f;if(t&2048)return!0;if(t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&j===null&&k(e,h)}return!1}function Wn(e,t,n=!0){var r=e.reactions;if(r!==null&&!(Fn!==null&&Fn.has(e)))for(var i=0;i{e.ac.abort(me)}),e.ac=null);try{e.f|=re;var u=e.fn,d=u();e.f|=b;var f=Kn(e);if(Ge()&&J!==null&&!U&&f!==null&&!(e.f&6146))for(var p=0;p0)for(t.length=q+K.length,r=0;r{s.ac.abort(me),s.ac=null,k(s,g)}),ht(s),Jn(s,0)}}function Jn(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?(i.__removed=!1,Je(()=>{i.__removed||t.addEventListener(e,i,r)})):t.addEventListener(e,i,r),i}function or(e,t,n,r,i){var a={capture:r,passive:i},o=ar(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&fn(()=>{o.__removed=!0,t.removeEventListener(e,o,a)})}function sr(e,t,n){(t[nr]??={})[e]=n}function cr(e){for(var t=0;t{ur=!1,lr=null}));var s=0,c=lr===e&&e[nr];if(c){var l=a.indexOf(c);if(l!==-1&&(t===document||t===window)){e[nr]=t;return}var u=a.indexOf(t);if(u===-1)return;l<=u&&(s=l)}if(o=a[s]||e.target,o!==t){i(e,`currentTarget`,{configurable:!0,get(){return o||n}});var d=H,f=G;W(null),Pn(null);try{for(var p,m=[];o!==null&&o!==t;){try{var h=o[nr]?.[r];h==null||o.disabled&&e.target!==o||h.call(o,e)}catch(e){p?m.push(e):p=e}if(e.cancelBubble)break;s++,o=s{throw e});throw p}}finally{e[nr]=t,delete e.currentTarget,W(d),Pn(f)}}}var fr=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function pr(e){return fr?.createHTML(e)??e}function mr(e){var t=rn(`template`);return t.innerHTML=pr(e.replaceAll(``,``)),t.content}function hr(e,t){var n=G;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function X(e,t){var n=!!(t&1),r=!!(t&2),i,a=!e.startsWith(``);return()=>{if(w)return hr(T,null),T;i===void 0&&(i=mr(a?e:``+e),n||(i=$t(i)));var t=r||Yt?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=$t(t),s=t.lastChild;hr(o,s)}else hr(t,t);return t}}function gr(e=``){if(!w){var t=P(e+``);return hr(t,t),t}var n=T;return n.nodeType===3?an(n):(n.before(n=P()),E(n)),hr(n,n),n}function _r(){if(w)return hr(T,null),T;var e=document.createDocumentFragment(),t=document.createComment(``),n=P();return e.append(t,n),hr(t,n),e}function Z(e,t){if(w){var n=G;n.f&32768&&n.nodes.end!==null||(n.nodes.end=T),Ce()}else e!==null&&e.before(t)}function vr(e){let t=0,n=Lt(0),r;return()=>{dn()&&(Y(n),vn(()=>(t===0&&(r=$n(()=>e(()=>Ut(n)))),t+=1,()=>{Je(()=>{--t,t===0&&(r?.(),r=void 0,Ut(n))})})))}}var yr=S|ee;function br(e,t,n,r){new xr(e,t,n,r)}var xr=class{parent;is_pending=!1;transform_error;#e;#t=w?T:null;#n;#r;#i;#a=null;#o=null;#s=null;#c=null;#l=0;#u=0;#d=!1;#f=new Set;#p=new Set;#m=null;#h=vr(()=>(this.#m=Lt(this.#l),()=>{this.#m=null}));constructor(e,t,n,r){this.#e=e,this.#n=t,this.#r=e=>{var t=G;t.b=this,t.f|=128,n(e)},this.parent=G.b,this.transform_error=r??this.parent?.transform_error??(e=>e),this.#i=yn(()=>{if(w){let e=this.#t;Ce();let t=e.data===`[!`;if(e.data.startsWith(`[?`)){let t=JSON.parse(e.data.slice(2));this.#_(t)}else t?this.#y():this.#g()}else this.#b()},yr),w&&(this.#e=T)}#g(){try{this.#a=B(()=>this.#r(this.#e))}catch(e){this.error(e)}}#_(e){let t=this.#n.failed,{reset:n,invoke_onerror:r}=this.#v(e);Je(r),t&&(this.#s=B(()=>{t(this.#e,()=>e,()=>n)}))}#v(e){var t=!1,n=!1;let r=()=>{t?xe():(t=!0,n&&Be(),this.#s!==null&&Tn(this.#s,()=>{this.#s=null}),this.#S(()=>{this.#b()}))};return{reset:r,invoke_onerror:()=>{try{n=!0,this.#n.onerror?.(e,r),n=!1}catch(e){sn(e,this.#i&&this.#i.parent)}}}}#y(){let e=this.#n.pending;e&&(this.is_pending=!0,this.#o=B(()=>e(this.#e)),Je(()=>{var e=this.#c=document.createDocumentFragment(),t=P(),n=!1;e.append(t),this.#a=this.#S(()=>{try{return B(()=>this.#r(t))}catch(e){try{this.error(e),n=!0}catch(e){sn(e,this.#i.parent)}return null}}),this.#a===null?(this.#c=null,n&&this.#x(A)):this.#u===0&&(this.#e.before(e),this.#c=null,Tn(this.#o,()=>{this.#o=null}),this.#x(A))}))}#b(){try{if(this.is_pending=this.has_pending_snippet(),this.#u=0,this.#l=0,this.#a=B(()=>{this.#r(this.#e)}),this.#u>0){var e=this.#c=document.createDocumentFragment();kn(this.#a,e);let t=this.#n.pending;this.#o=B(()=>t(this.#e))}else this.#x(A)}catch(e){this.error(e)}}#x(e){this.is_pending=!1,e.transfer_effects(this.#f,this.#p)}defer_effect(e){Qe(e,this.#f,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#n.pending}#S(e){var t=G,n=H,r=O;Pn(this.#i),W(this.#i),Ve(this.#i.ctx);try{return Et.ensure(),e()}finally{Pn(t),W(n),Ve(r)}}#C(e,t){this.has_pending_snippet()?(this.#u+=e,this.#u===0&&(this.#x(t),this.#o&&Tn(this.#o,()=>{this.#o=null}),this.#c&&=(this.#e.before(this.#c),null))):this.parent&&this.parent.#C(e,t)}update_pending_count(e,t){this.#C(e,t),this.#l+=e,!(!this.#m||this.#d)&&(this.#d=!0,Je(()=>{this.#d=!1,this.#m&&Vt(this.#m,this.#l)}))}get_effect_pending(){return this.#h(),Y(this.#m)}error(e){if(!this.#n.onerror&&!this.#n.failed)throw e;A?.is_fork?(this.#a&&A.skip_effect(this.#a),this.#o&&A.skip_effect(this.#o),this.#s&&A.skip_effect(this.#s),A.oncommit(()=>{this.#w(e)})):this.#w(e)}#w(e){this.#a&&=(V(this.#a),null),this.#o&&=(V(this.#o),null),this.#s&&=(V(this.#s),null),w&&(E(this.#t),we(),E(Te()));let t=this.#n.failed,n=e=>{let{reset:n,invoke_onerror:r}=this.#v(e);r(),t&&(this.#s=this.#S(()=>{try{return B(()=>{var r=G;r.b=this,r.f|=128,t(this.#e,()=>e,()=>n)})}catch(e){return sn(e,this.#i.parent),null}}))};Je(()=>{var t;try{t=this.transform_error(e)}catch(e){sn(e,this.#i&&this.#i.parent);return}typeof t==`object`&&t&&typeof t.then==`function`?t.then(n,e=>sn(e,this.#i&&this.#i.parent)):n(t)})}};function Q(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[fe]??=e.nodeValue)&&(e[fe]=n,e.nodeValue=`${n}`)}function Sr(e,t){return wr(e,t)}var Cr=new Map;function wr(e,{target:t,anchor:n,props:i={},events:a,context:o,intro:s=!0,transformError:c}){Qt();var l=void 0,u=hn(()=>{var s=n??t.appendChild(P());br(s,{pending:()=>{}},t=>{He({});var n=O;if(o&&(n.c=o),a&&(i.$$events=a),w&&hr(t,null),l=e(t,i)||We(),w&&(G.nodes.end=T,T===null||T.nodeType!==8||T.data!==`]`))throw ye(),ge;Ue()},c);var u=new Set,d=e=>{for(var n=0;n{for(var e of u)for(let n of[t,document]){var r=Cr.get(n),i=r.get(e);--i==0?(n.removeEventListener(e,dr),r.delete(e),r.size===0&&Cr.delete(n)):r.set(e,i)}ir.delete(d),s!==n&&s.parentNode?.removeChild(s)}});return Tr.set(l,u),l}var Tr=new WeakMap,Er=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)Dn(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(Dn(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(V(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();kn(r,t),t.append(P()),this.#n.set(e,{effect:r,fragment:t})}else V(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),Tn(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(V(n.effect),this.#n.delete(e))};ensure(e,t){var n=A,r=nn();if(t&&!this.#t.has(e)&&!this.#n.has(e)){if(r){var i=document.createDocumentFragment(),a=P();i.append(a),this.#n.set(e,{effect:B(()=>t(a)),fragment:i})}else this.#t.set(e,B(()=>t(this.anchor)))}if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else w&&(this.anchor=T),this.#a(n)}};function $(e,t,n=!1){var r;w&&(r=T,Ce());var i=new Er(e),a=n?S:0;function o(e,t){if(w){var n=Ee(r);if(e!==parseInt(n.substring(1))){var a=Te();E(a),i.anchor=a,Se(!1),i.ensure(e,t),Se(!0);return}}i.ensure(e,t)}yn(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}var Dr=Symbol(`NaN`);function Or(e,t,n){w&&Ce();var r=new Er(e),i=!Ge();yn(()=>{var e=t();e!==e&&(e=Dr),i&&typeof e==`object`&&e&&(e={}),r.ensure(e,n)})}function kr(e,t){return t}function Ar(e,t,n){for(var i=[],a=t.length,o,s=t.length,c=0;c{if(o){if(o.pending.delete(n),o.done.add(n),o.pending.size===0){var t=e.outrogroups;jr(e,r(o.done)),t.delete(o),t.size===0&&(e.outrogroups=null)}}else--s},!1)}if(s===0){var l=i.length===0&&n!==null&&e.pending.size===0;if(l){var u=n,d=u.parentNode;tn(d),d.append(u),e.items.clear()}jr(e,t,!l)}else o={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(o)}function jr(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(let t of e.pending.values())for(let n of t)r.add(e.items.get(n).e)}for(var i=0;i{var t=i();return e(t)?t:t==null?[]:r(t)}),p,m=new Map,h=!0;function g(e){v.effect.f&16384||(v.pending.delete(e),v.fallback=d,Fr(v,p,c,n,a),d!==null&&(p.length===0?d.f&33554432?(d.f^=ne,Lr(d,null,c)):Dn(d):Tn(d,()=>{d=null})))}function _(e){v.pending.delete(e)}var v={effect:yn(()=>{p=Y(f);var e=p.length;let t=!1;w&&Ee(c)===`[!`!=(e===0)&&(c=Te(),E(c),Se(!1),t=!0);for(var r=new Set,u=A,v=nn(),y=0;ys(c)):(d=B(()=>s(Mr??=P())),d.f|=ne)),e>r.size&&Me(``,``,``),w&&e>0&&E(Te()),!h){if(m.set(u,r),v){for(let[e,t]of l)r.has(e)||u.skip_effect(t.e);u.oncommit(g),u.ondiscard(_)}else g(u)}t&&Se(!0),Y(f)}),flags:n,items:l,pending:m,outrogroups:null,fallback:d};h=!1,w&&(c=T)}function Pr(e){for(;e!==null&&!(e.f&32);)e=e.next;return e}function Fr(e,t,n,i,a){var o=!!(i&8),s=t.length,c=e.items,l=Pr(e.effect.first),u,d=null,f,p=[],m=[],h,g,_,v;if(o)for(v=0;v0){var ie=i&4&&s===0?n:null;if(o){for(v=0;v{if(f!==void 0)for(_ of f)_.nodes?.a?.apply()})}function Ir(e,t,n,r,i,a,o,s){var c=o&1?o&16?Lt(n):Rt(n,!1,!1):null,l=o&2?Lt(i):null;return{v:c,i:l,e:B(()=>(a(t,c??n,l??i,s),()=>{e.delete(r)}))}}function Lr(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,a=t&&!(t.f&33554432)?t.nodes.start:n;r!==null;){var o=en(r);if(a.before(r),r===i)return;r=o}}function Rr(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}var zr=[...` -\r\f\xA0\v`];function Br(e,t,n){var r=e==null?``:``+e;if(t&&(r=r?r+` `+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+` `+i:i;else if(r.length)for(var a=i.length,o=0;(o=r.indexOf(i,o))>=0;){var s=o+a;(o===0||zr.includes(r[o-1]))&&(s===r.length||zr.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function Vr(e,t,n,r,i,a){var o=e[ue];if(w||o!==n||o===void 0){var s=Br(n,r,a);w&&s===e.getAttribute(`class`)||(s==null?e.removeAttribute(`class`):t?e.className=s:e.setAttribute(`class`,s)),e[ue]=n}else if(a&&i!==a)for(var c in a){var l=!!a[c];(i==null||l!==!!i[c])&&e.classList.toggle(c,l)}return a}function Hr(e,t){t?e.hasAttribute(`selected`)||e.setAttribute(`selected`,``):e.removeAttribute(`selected`)}function Ur(t,n){var r=t.__defaultValue,i=t.multiple,a=i?r??[]:null;if(!i||e(a)){var o=t.selectedIndex,s=n&&i?new Set(t.selectedOptions):null;for(var c of t.options){var l=qr(c);Hr(c,i?a.includes(l):qt(l,r))}if(n){if(s!==null)for(c of t.options){var u=s.has(c);c.selected!==u&&(c.selected=u)}else t.selectedIndex!==o&&(t.selectedIndex=o)}}}function Wr(t,n,r=!1){if(t.multiple){if(n==null)return;if(!e(n))return be();for(var i of t.options)i.selected=n.includes(qr(i))}else{for(i of t.options)if(qt(qr(i),n)){i.selected=!0;return}r&&n===void 0||(t.selectedIndex=-1)}}function Gr(e){var t=new MutationObserver(t=>{t.every(Jr)||(`__defaultValue`in e&&Ur(e,!1),`__value`in e&&Wr(e,e.__value))});t.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`value`]}),fn(()=>{t.disconnect()})}function Kr(e,t,n=t){var r=new WeakSet,i=!0;nt(e,`change`,t=>{var i=t?`[selected]`:`:checked`,a;if(e.multiple)a=[].map.call(e.querySelectorAll(i),qr);else{var o=e.querySelector(i)??e.querySelector(`option:not([disabled])`);a=o&&qr(o)}n(a),e.__value=a,A!==null&&r.add(A)}),gn(()=>{var a=t();if(e===document.activeElement){var o=A;if(r.has(o))return}if(Wr(e,a,i),i&&a===void 0){var s=e.querySelector(`:checked`);s!==null&&(a=qr(s),n(a))}e.__value=a,i=!1})}function qr(e){return`__value`in e?e.__value:e.value}function Jr(e){if(e.target.closest(`selectedcontent`)!==null)return!0;if(e.type===`childList`){var t=[...e.addedNodes,...e.removedNodes];return t.length>0&&t.every(e=>e.nodeName===`SELECTEDCONTENT`)}return!1}var Yr=Symbol(`is custom element`),Xr=Symbol(`is html`),Zr=he?`link`:`LINK`;function Qr(e){if(w){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute(`value`)){var n=e.value;$r(e,`value`,null),e.value=n}if(e.hasAttribute(`checked`)){var r=e.checked;$r(e,`checked`,null),e.checked=r}}};e[pe]=n,Je(n),et()}}function $r(e,t,n,r){var i=ei(e);w&&(i[t]=e.getAttribute(t),t===`src`||t===`srcset`||t===`href`&&e.nodeName===Zr)||i[t]!==(i[t]=n)&&(t===`loading`&&(e[ce]=n),n==null?e.removeAttribute(t):typeof n!=`string`&&ni(e).has(t)?e[t]=n:e.setAttribute(t,n))}function ei(e){return e[le]??={[Yr]:e.nodeName.includes(`-`),[Xr]:e.namespaceURI===_e}}var ti=new Map;function ni(e){var t=e.getAttribute(`is`)||e.nodeName,n=ti.get(t);if(n)return n;ti.set(t,n=new Set);for(var r,i=e,a=Element.prototype;a!==i;){for(var s in r=o(i),r)r[s].set&&s!==`innerHTML`&&s!==`textContent`&&s!==`innerText`&&n.add(s);i=l(i)}return n}function ri(e,t,n=t){var r=new WeakSet;nt(e,`input`,async i=>{var a=i?e.defaultValue:e.value;if(a=ai(e)?oi(a):a,n(a),A!==null&&r.add(A),await Xn(),a!==(a=t())){var o=e.selectionStart,s=e.selectionEnd,c=e.value.length;if(e.value=a??``,s!==null){var l=e.value.length;o===s&&s===c&&l>c?(e.selectionStart=l,e.selectionEnd=l):(e.selectionStart=o,e.selectionEnd=Math.min(s,l))}}}),(w&&e.defaultValue!==e.value||$n(t)==null&&e.value)&&(n(ai(e)?oi(e.value):e.value),A!==null&&r.add(A)),vn(()=>{var n=t();if(e===document.activeElement){var i=A;if(r.has(i))return}ai(e)&&n===oi(e.value)||(e.type!==`date`||n||e.value)&&n!==e.value&&(e.value=n??``)})}function ii(e,t,n=t){nt(e,`change`,t=>{n(t?e.defaultChecked:e.checked)}),(w&&e.defaultChecked!==e.checked||$n(t)==null)&&n(e.checked),vn(()=>{e.checked=!!t()})}function ai(e){var t=e.type;return t===`number`||t===`range`}function oi(e){return e===``?null:+e}function si(e){O===null&&Ae(`onMount`),pn(()=>{let t=$n(e);if(typeof t==`function`)return t})}typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);var ci=``,li=class extends Error{constructor(e,t){super(t),this.status=e}};async function ui(e,t,n){let r={Accept:`application/json`};e!==`GET`&&(r[`Content-Type`]=`application/json`,r[`X-Rowbit-Token`]=ci);let i=await fetch(t,{method:e,headers:r,credentials:`same-origin`,body:n===void 0?void 0:JSON.stringify(n)});if(i.status===401)throw window.location.assign(`/login`),new li(401,`Signed out`);let a=await i.json().catch(()=>({}));if(!i.ok)throw new li(i.status,a.error||`HTTP ${i.status}`);return a}async function di(){let e=await ui(`GET`,`/api/session`);return ci=e.token,e}var fi=(e={})=>ui(`GET`,`/api/changes?${new URLSearchParams(Object.entries(e).filter(([,e])=>e))}`),pi=(e,t=0)=>ui(`GET`,`/api/changes/${e}?offset=${t}`),mi=(e,t,n)=>ui(`POST`,`/api/changes/${e}/approve`,{review:t,rows:n}),hi=(e,t)=>ui(`POST`,`/api/changes/${e}/reject`,{reason:t}),gi=e=>ui(`POST`,`/api/changes/${e}/undo`,{}),_i=()=>ui(`GET`,`/api/verify`),vi=()=>ui(`GET`,`/api/deliveries`),yi=()=>ui(`POST`,`/api/logout`,{});function bi(e,t,n=`${t}s`){return`${e.toLocaleString(`en`)} ${e===1?t:n}`}function xi(e){let t=Math.max(0,Math.round((Date.now()-e)/1e3));return t<60?`${t}s ago`:t<3600?`${Math.round(t/60)} min ago`:t<86400?`${Math.round(t/3600)} h ago`:`${Math.round(t/86400)} d ago`}function Si(e){return new Date(e).toLocaleString()}var Ci={insert:`inserts`,update:`updates`,delete:`deletes`,ddl:`changes the schema`},wi=X(` `);function Ti(e,t){var n=wi(),r=L(n,!0);z(()=>{Vr(n,1,`state ${t.state??``}`),Q(r,t.state)}),Z(e,n)}var Ei=X(`-`),Di=X(`NULL`),Oi=X(` `),ki=X(` `),Ai=X(` `);function ji(e,t){He(t,!0);var n=_r(),r=I(n),i=e=>{Z(e,Ei())},a=e=>{Z(e,Di())},o=e=>{var n=Oi(),r=L(n,!0);z(()=>Q(r,t.value.text)),Z(e,n)},s=e=>{var n=ki(),r=L(n,!0);z(()=>Q(r,t.value.text)),Z(e,n)},c=e=>{var n=Ai(),r=L(n);z(()=>Q(r,`"${t.value.text??``}"`)),Z(e,n)};$(r,e=>{t.value==null?e(i):t.value.kind===`null`?e(a,1):t.value.kind===`number`?e(o,2):t.value.kind===`binary`?e(s,3):e(c,-1)}),Z(e,n),Ue()}var Mi=X(``),Ni=X(`

`),Pi=X(`

`),Fi=X(`

Runs this schema change, which can't be undone:

 
`,1),Ii=X(``,1),Li=X(`new`),Ri=X(` `),zi=X(`

`),Bi=X(`

in

RowColumnBeforeAfter
`,1),Vi=X(``),Hi=X(`

`,1),Ui=X(`
Restores every row as it was, unless one changed since.
`),Wi=X(` `),Gi=X(`

 

History

WhenEventStateByDetail
`,1),Ki=X(`

Back to the queue

`,1);function qi(e,t){He(t,!0);let n=M(null),r=M(``),i=M(!1),a=M(!1),o=M(``),s=M(``);async function c(e=0){try{N(n,await pi(t.id,e),!0),N(r,``)}catch(e){N(r,e.message,!0)}}pn(()=>{c()});async function l(e){N(i,!0);try{N(n,await e(),!0),N(r,``)}catch(e){N(r,e.message,!0)}finally{N(i,!1)}}let u=ut(()=>Y(n)?.preview?.total_rows??0),d=ut(()=>Y(n)?.preview?Y(u)===1?`Approve and apply 1 row`:`Approve and apply all ${Y(u).toLocaleString(`en`)} rows`:`Approve and run`),f=ut(()=>!Y(n)?.confirm_rows||Number(Y(o))===Y(u)),p=ut(()=>Y(n)?.preview?Math.floor(Y(n).preview.offset/50):0),m=ut(()=>Math.ceil(Y(u)/50));var h=Ki(),g=R(I(h),2),_=e=>{var t=Mi(),n=L(t,!0);z(()=>Q(n,Y(r))),Z(e,t)};$(g,e=>{Y(r)&&e(_)});var v=R(g,2),y=e=>{var t=Gi(),r=I(t),h=F(r),g=F(h),_=L(g);Ti(R(g,2),{get state(){return Y(n).state}}),D(h);var v=R(h,2),y=e=>{var t=Ni(),r=L(t,!0);z(()=>Q(r,Y(n).reason)),Z(e,t)};$(v,e=>{Y(n).reason&&e(y)});var b=R(v,2),x=e=>{var t=Pi(),r=L(t);z(()=>Q(r,`Shadow mode: ${Y(n).shadowed.would??``} (${Y(n).shadowed.by??``}). It was applied anyway.`)),Z(e,t)};$(b,e=>{Y(n).shadowed&&e(x)});var S=R(b,2),ee=L(S,!0),te=R(S,2),ne=e=>{var t=Fi(),r=L(R(I(t),2),!0);z(()=>Q(r,Y(n).ddl)),Z(e,t)},re=e=>{var t=Bi(),r=I(t),i=F(r),o=R(i),s=L(o,!0),l=R(o,2),d=L(l,!0),f=R(l,2),h=F(f);Qr(h),we(),D(f),D(r);var g=R(r,2),_=F(g),v=R(F(_));Nr(v,21,()=>Y(n).preview.rows,kr,(e,t)=>{var n=_r();Nr(I(n),17,()=>Y(t).cells.filter(e=>Y(a)||e.changed),kr,(e,n,r)=>{var i=Ri();Vr(i,1,``,null,{},{first:r===0});var a=F(i),o=F(a),s=e=>{var n=_r(),r=I(n),i=e=>{var n=_r();Nr(I(n),17,()=>Y(t).key,kr,(e,t,n)=>{var r=Ii(),i=I(r),a=e=>{Z(e,gr(`,`))};$(i,e=>{n>0&&e(a)}),ji(R(i),{get value(){return Y(t)}}),Z(e,r)}),Z(e,n)},a=e=>{Z(e,Li())};$(r,e=>{Y(t).key?e(i):e(a,-1)}),Z(e,n)};$(o,e=>{r===0&&e(s)}),D(a);var c=R(a),l=L(c,!0),u=R(c);let d;ji(F(u),{get value(){return Y(n).before}}),D(u);var f=R(u);let p;ji(F(f),{get value(){return Y(n).after}}),D(f),D(i),z(()=>{Q(l,Y(n).column),d=Vr(u,1,`before`,null,d,{same:!Y(n).changed}),p=Vr(f,1,`after`,null,p,{same:!Y(n).changed})}),Z(e,i)}),Z(e,n)}),D(v),D(_),D(g);var y=R(g,2),b=e=>{var t=zi(),r=F(t),i=R(r),a=R(i);D(t),z((e,t)=>{r.disabled=Y(p)===0,Q(i,` Rows ${Y(n).preview.offset+1} to ${e??``} of - ${t??``} `),a.disabled=Y(p)+1>=Y(m)},[()=>Math.min(Y(n).preview.offset+50,Y(u)),()=>Y(u).toLocaleString(`en`)]),sr(`click`,r,()=>c((Y(p)-1)*50)),sr(`click`,a,()=>c((Y(p)+1)*50)),Z(e,t)};$(y,e=>{Y(m)>1&&e(b)}),z(e=>{Q(i,`${Ci[Y(n).preview.kind]??``} `),Q(s,e),Q(d,Y(n).preview.table)},[()=>bi(Y(u),`row`)]),ii(h,()=>Y(a),e=>N(a,e)),Z(e,t)};$(te,e=>{Y(n).ddl?e(ne):Y(n).preview&&e(re,1)});var ie=R(te,2),ae=e=>{var t=Hi(),r=I(t),a=F(r),c=F(a),p=e=>{var t=Vi(),n=R(F(t));Qr(n),D(t),z(e=>$r(n,`placeholder`,e),[()=>String(Y(u))]),ri(n,()=>Y(o),e=>N(o,e)),Z(e,t)};$(c,e=>{Y(n).confirm_rows&&e(p)});var m=R(c,2),h=L(m,!0);D(a);var g=R(a,2),_=F(g);Qr(_);var v=R(_,2);D(g),D(r);var y=L(R(r,2));z(e=>{m.disabled=Y(i)||!Y(f),Q(h,Y(d)),v.disabled=Y(i),Q(y,`Approving covers review ${e??``}: exactly the rows above.`)},[()=>Y(n).review_hash?.slice(0,12)]),or(`submit`,a,e=>{e.preventDefault(),l(()=>mi(Y(n).id,Y(n).review_hash,Y(n).confirm_rows?Number(Y(o)):void 0))}),or(`submit`,g,e=>{e.preventDefault(),l(()=>hi(Y(n).id,Y(s)))}),ri(_,()=>Y(s),e=>N(s,e)),Z(e,t)};$(ie,e=>{Y(n).state===`held`&&e(ae)});var oe=R(ie,2),se=e=>{var t=Ui(),r=F(t);we(2),D(t),z(()=>r.disabled=Y(i)),sr(`click`,r,()=>l(()=>gi(Y(n).id))),Z(e,t)};$(oe,e=>{Y(n).undoable&&e(se)}),D(r);var ce=R(r,4),le=R(F(ce));Nr(le,21,()=>Y(n).history,e=>e.seq,(e,t)=>{var n=Wi(),r=F(n),i=L(r,!0),a=R(r),o=L(a,!0),s=R(a),c=L(s,!0),l=R(s),u=L(l,!0),d=L(R(l),!0);D(n),z(e=>{Q(i,e),Q(o,Y(t).event),Q(c,Y(t).to),Q(u,Y(t).actor),Q(d,Y(t).detail??``)},[()=>Si(Y(t).at_ms)]),Z(e,n)}),D(le),D(ce),z(()=>{Q(_,`#${Y(n).id??``} from ${Y(n).agent??``}`),Q(ee,Y(n).statement)}),Z(e,t)};$(v,e=>{Y(n)&&e(y)}),Z(e,h),Ue()}cr([`click`]);var Ji=X(`

`),Yi=X(`

Nothing sent yet.

`),Xi=X(` `),Zi=X(`
ChangeTargetStateTriesLast errorQueued
`),Qi=X(`

Notifications

Held changes announced to the targets in rowbit-server.yml. A notification that - can't be delivered never affects the change; it stays held here.

`,1);function $i(e,t){He(t,!0);let n=M(null),r=M(``);async function i(){try{N(n,(await vi()).deliveries,!0),N(r,``)}catch(e){N(r,e.message,!0)}}si(()=>{i();let e=setInterval(i,1e4);return()=>clearInterval(e)});var a=Qi(),o=R(I(a),4),s=e=>{var t=Ji(),n=L(t,!0);z(()=>Q(n,Y(r))),Z(e,t)};$(o,e=>{Y(r)&&e(s)});var c=R(o,2),l=e=>{Z(e,Yi())};$(c,e=>{Y(n)&&Y(n).length===0&&e(l)});var u=R(c,2),d=e=>{var t=Zi(),r=F(t),i=R(F(r));Nr(i,21,()=>Y(n),e=>e.id,(e,t)=>{var n=Xi(),r=F(n),i=F(r),a=L(i);D(r);var o=R(r),s=L(o,!0),c=R(o),l=F(c),u=L(l,!0);D(c);var d=R(c),f=L(d,!0),p=R(d),m=L(p,!0),h=L(R(p),!0);D(n),z(e=>{$r(i,`href`,`#/changes/${Y(t).change_id??``}`),Q(a,`#${Y(t).change_id??``}`),Q(s,Y(t).target),Vr(l,1,`state ${Y(t).state===`delivered`?`applied`:Y(t).state===`failed`?`failed`:`held`}`),Q(u,Y(t).state),Q(f,Y(t).attempts),Q(m,Y(t).last_error??``),Q(h,e)},[()=>xi(Y(t).created_at_ms)]),Z(e,n)}),D(i),D(r),D(t),Z(e,t)};$(u,e=>{Y(n)&&Y(n).length&&e(d)}),Z(e,a),Ue()}var ea=X(`

The audit log's hash chain checks out.

`),ta=X(``),na=X(``),ra=X(`

`),ia=X(` `),aa=X(` `),oa=X(`

Log

#StateAgentWhatDecided byReasonWhen
`,1);function sa(e,t){He(t,!0);let n=[``,`proposed`,`held`,`approved`,`applied`,`rejected`,`failed`,`undone`],r=Gt({state:``,agent:``,table:``}),i=M(Gt([])),a=M(null),o=M(``);async function s(){try{N(i,(await fi(r)).changes,!0),N(o,``)}catch(e){N(o,e.message,!0)}}si(async()=>{s(),N(a,await _i().catch(e=>({ok:!1,problem:e.message})),!0)});var c=oa(),l=R(I(c),2),u=e=>{var t=_r(),n=I(t),r=e=>{Z(e,ea())},i=e=>{var t=ta(),n=L(t);z(()=>Q(n,`The audit log doesn't check out: ${Y(a).problem??``}`)),Z(e,t)};$(n,e=>{Y(a).ok?e(r):e(i,-1)}),Z(e,t)};$(l,e=>{Y(a)&&e(u)});var d=R(l,2),f=F(d),p=R(F(f));Nr(p,20,()=>n,e=>e,(e,t)=>{var n=na(),r=L(n,!0),i={};z(()=>{Q(r,t||`any`),i!==(i=t)&&(n.value=(n.__value=i)??``)}),Z(e,n)}),D(p),Gr(p),D(f);var m=R(f,2),h=R(F(m));Qr(h),D(m);var g=R(m,2),_=R(F(g));Qr(_),D(g),we(2),D(d);var v=R(d,2),y=e=>{var t=ra(),n=L(t,!0);z(()=>Q(n,Y(o))),Z(e,t)};$(v,e=>{Y(o)&&e(y)});var b=R(v,2),x=F(b),S=R(F(x));Nr(S,21,()=>Y(i),e=>e.id,(e,t)=>{var n=aa(),r=F(n),i=F(r),a=L(i,!0);D(r);var o=R(r);Ti(F(o),{get state(){return Y(t).state}}),D(o);var s=R(o),c=L(s,!0),l=R(s),u=F(l),d=R(u),f=e=>{var n=gr();z(()=>Q(n,Y(t).rows)),Z(e,n)};$(d,e=>{Y(t).rows!=null&&e(f)});var p=R(d,2),m=e=>{var n=ia(),r=L(n,!0);z(()=>Q(r,Y(t).table)),Z(e,n)};$(p,e=>{Y(t).table&&e(m)}),D(l);var h=R(l),g=L(h,!0),_=R(h),v=L(_,!0),y=L(R(_),!0);D(n),z(e=>{$r(i,`href`,`#/changes/${Y(t).id??``}`),Q(a,Y(t).id),Q(c,Y(t).agent),Q(u,`${Ci[Y(t).kind]??``??``} `),Q(g,Y(t).decided_by??``),Q(v,Y(t).reason??``),Q(y,e)},[()=>xi(Y(t).created_at_ms)]),Z(e,n)}),D(S),D(x),D(b),or(`submit`,d,e=>{e.preventDefault(),s()}),Kr(p,()=>r.state,e=>r.state=e),ri(h,()=>r.agent,e=>r.agent=e),ri(_,()=>r.table,e=>r.table=e),Z(e,c),Ue()}var ca=X(`

`),la=X(`

Nothing is waiting.

`),ua=X(` in`,1),da=X(` `),fa=X(`
`),pa=X(`
`),ma=X(`

Waiting for a person

`,1);function ha(e,t){He(t,!0);let n=M(null),r=M(Gt([])),i=M(``);async function a(){try{let e=await fi({state:`held`});N(n,e.changes.reverse(),!0),N(r,e.interrupted,!0),N(i,``)}catch(e){N(i,e.message,!0)}}si(()=>{a();let e=setInterval(a,1e4);return()=>clearInterval(e)});var o=ma(),s=I(o),c=R(F(s)),l=e=>{var t=gr();z(()=>Q(t,`(${Y(n).length??``})`)),Z(e,t)};$(c,e=>{Y(n)&&e(l)}),D(s);var u=R(s,2),d=e=>{var t=ca(),n=L(t);z(e=>Q(n,`Changes ${e??``} were interrupted while applying or undoing. Compare their rows - with the preview before anything else.`),[()=>Y(r).join(`, `)]),Z(e,t)};$(u,e=>{Y(r).length&&e(d)});var f=R(u,2),p=e=>{var t=ca(),n=L(t,!0);z(()=>Q(n,Y(i))),Z(e,t)};$(f,e=>{Y(i)&&e(p)});var m=R(f,2),h=e=>{Z(e,la())};$(m,e=>{Y(n)&&Y(n).length===0&&e(h)}),Nr(R(m,2),17,()=>Y(n)??[],e=>e.id,(e,t)=>{var n=pa(),r=F(n),i=F(r),a=L(i),o=R(i,2),s=L(o,!0),c=L(R(o,2),!0);D(r);var l=R(r,2),u=F(l),d=R(u),f=e=>{var n=ua(),r=L(I(n),!0);we(),z(e=>Q(r,e),[()=>bi(Y(t).rows,`row`)]),Z(e,n)};$(d,e=>{Y(t).rows!=null&&e(f)});var p=R(d,2),m=e=>{var n=da(),r=L(n,!0);z(()=>Q(r,Y(t).table)),Z(e,n)};$(p,e=>{Y(t).table&&e(m)}),D(l);var h=R(l,2),g=e=>{var n=fa(),r=L(n);z(()=>Q(r,`Held: ${Y(t).reason??``}`)),Z(e,n)};$(h,e=>{Y(t).reason&&e(g)}),D(n),z(e=>{$r(n,`href`,`#/changes/${Y(t).id??``}`),Q(a,`#${Y(t).id??``}`),Q(s,Y(t).agent),Q(c,e),Q(u,`${Ci[Y(t).kind]??`writes`??``} `)},[()=>xi(Y(t).created_at_ms)]),Z(e,n)}),Z(e,o),Ue()}var ga=X(`

`),_a=X(`

Nothing yet.

`),va=X(` `),ya=X(` `),ba=X(`

#StateAgentWhatWould haveWhen
`,1),xa=X(`

Shadow mode

Changes from agents in shadow mode that a rule would have held or blocked. They were applied, and - can be undone from their page.

`,1);function Sa(e,t){He(t,!0);let n=M(null),r=M(``);si(async()=>{try{let{changes:e}=await fi({shadowed:`true`}),t=new Map;for(let n of e){let e=n.shadowed.by;t.has(e)||t.set(e,[]),t.get(e).push(n)}N(n,[...t.entries()].sort((e,t)=>t[1].length-e[1].length),!0)}catch(e){N(r,e.message,!0)}});var i=xa(),a=R(I(i),4),o=e=>{var t=ga(),n=L(t,!0);z(()=>Q(n,Y(r))),Z(e,t)};$(a,e=>{Y(r)&&e(o)});var s=R(a,2),c=e=>{Z(e,_a())};$(s,e=>{Y(n)&&Y(n).length===0&&e(c)}),Nr(R(s,2),17,()=>Y(n)??[],([e,t])=>e,(e,t)=>{var n=ut(()=>m(Y(t),2));let r=()=>Y(n)[0],i=()=>Y(n)[1];var a=ba(),o=I(a),s=F(o),c=L(R(s));D(o);var l=R(o,2),u=F(l),d=R(F(u));Nr(d,21,i,e=>e.id,(e,t)=>{var n=ya(),r=F(n),i=F(r),a=L(i,!0);D(r);var o=R(r);Ti(F(o),{get state(){return Y(t).state}}),D(o);var s=R(o),c=L(s,!0),l=R(s),u=F(l),d=R(u),f=e=>{var n=gr();z(()=>Q(n,Y(t).rows)),Z(e,n)};$(d,e=>{Y(t).rows!=null&&e(f)});var p=R(d,2),m=e=>{var n=va(),r=L(n,!0);z(()=>Q(r,Y(t).table)),Z(e,n)};$(p,e=>{Y(t).table&&e(m)}),D(l);var h=R(l),g=L(h,!0),_=L(R(h),!0);D(n),z(e=>{$r(i,`href`,`#/changes/${Y(t).id??``}`),Q(a,Y(t).id),Q(c,Y(t).agent),Q(u,`${Ci[Y(t).kind]??``??``} `),Q(g,Y(t).shadowed.would??``),Q(_,e)},[()=>xi(Y(t).created_at_ms)]),Z(e,n)}),D(d),D(u),D(l),z(e=>{Q(s,`${r()??``} `),Q(c,`(${e??``})`)},[()=>bi(i().length,`change`)]),Z(e,a)}),Z(e,i),Ue()}var Ca=X(` `,1),wa=X(`

Loading...

`),Ta=X(`
Rowbit
`,1);function Ea(e,t){He(t,!0);let n=M(``),r=M(Gt(location.hash));si(()=>{di().then(e=>N(n,e.reviewer,!0));let e=()=>N(r,location.hash,!0);return addEventListener(`hashchange`,e),()=>removeEventListener(`hashchange`,e)});let i=ut(()=>Y(r).match(/^#\/changes\/(\d+)$/)?.[1]);async function a(){await yi().catch(()=>{}),location.assign(`/login`)}var o=Ta(),s=I(o),c=R(F(s),2),l=F(c);let u;var d=R(l,2);let f;var p=R(d,2);let m;var h=R(p,2);let g;D(c);var _=R(c,2),v=F(_),y=e=>{var t=Ca(),r=I(t),i=R(r);z(()=>Q(r,`${Y(n)??``} `)),sr(`click`,i,a),Z(e,t)};$(v,e=>{Y(n)&&e(y)}),D(_),D(s);var b=R(s,2),x=F(b),S=e=>{Z(e,wa())},ee=e=>{var t=_r();Or(I(t),()=>Y(i),e=>{qi(e,{get id(){return Y(i)}})}),Z(e,t)},te=e=>{sa(e,{})},ne=e=>{Sa(e,{})},re=e=>{$i(e,{})},ie=e=>{ha(e,{})};$(x,e=>{Y(n)?Y(i)?e(ee,1):Y(r)===`#/log`?e(te,2):Y(r)===`#/shadow`?e(ne,3):Y(r)===`#/notifications`?e(re,4):e(ie,-1):e(S)}),D(b),z(()=>{u=Vr(l,1,``,null,u,{active:Y(r)===``||Y(r)===`#/`}),f=Vr(d,1,``,null,f,{active:Y(r)===`#/log`}),m=Vr(p,1,``,null,m,{active:Y(r)===`#/shadow`}),g=Vr(h,1,``,null,g,{active:Y(r)===`#/notifications`})}),Z(e,o),Ue()}cr([`click`]),Sr(Ea,{target:document.getElementById(`app`)}); \ No newline at end of file +var e=Array.isArray,t=Array.prototype.indexOf,n=Array.prototype.includes,r=Array.from,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyDescriptors,s=Object.prototype,c=Array.prototype,l=Object.getPrototypeOf,u=Object.isExtensible,d=()=>{};function f(e){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}function m(e,t){if(Array.isArray(e))return e;if(t===void 0||!(Symbol.iterator in e))return Array.from(e);let n=[];for(let r of e)if(n.push(r),n.length===t)break;return n}var h=1024,g=2048,_=4096,v=8192,y=16384,b=32768,x=1<<25,S=65536,C=1<<19,ee=1<<20,te=1<<25,ne=1<<21,re=1<<22,ie=1<<23,ae=Symbol(`$state`),oe=Symbol(`component`),se=Symbol(``),ce=Symbol(`attributes`),le=Symbol(`class`),ue=Symbol(`style`),de=Symbol(`text`),fe=Symbol(`form reset`),pe=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"},me=!!globalThis.document?.contentType&&globalThis.document.contentType.includes(`xml`),he={},w=Symbol(`uninitialized`),ge=`http://www.w3.org/1999/xhtml`;function _e(){console.warn(`https://svelte.dev/e/derived_inert`)}function ve(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}function ye(){console.warn(`https://svelte.dev/e/select_multiple_invalid_value`)}function be(){console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`)}var T=!1;function xe(e){T=e}var E;function D(e){if(e===null)throw ve(),he;return E=e}function Se(){return D(en(E))}function O(e){if(T){if(en(E)!==null)throw ve(),he;E=e}}function Ce(e=1){if(T){for(var t=e,n=E;t--;)n=en(n);E=n}}function we(e=!0){for(var t=0,n=E;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else(r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=en(n);e&&n.remove(),n=i}}function Te(e){if(!e||e.nodeType!==8)throw ve(),he;return e.data}function Ee(e){return e===this.v}function De(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function Oe(e){return!De(e,this.v)}function ke(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function Ae(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function je(e,t,n){throw Error(`https://svelte.dev/e/each_key_duplicate`)}function Me(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function Ne(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function Pe(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function Fe(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function Ie(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function Le(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function Re(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function ze(){throw Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`)}var k=null;function Be(e){k=e}function Ve(e,t=!1,n){k={p:k,i:!1,c:null,e:null,s:e,x:null,r:W,l:null}}function He(e){var t=k,n=t.e;if(n!==null){t.e=null;for(var r of n)mn(r)}return e!==void 0&&(t.x=e),t.i=!0,k=t.p,Ue(e)}function Ue(e={}){return i(e,oe,{value:!0}),e}function We(){return!0}var Ge=[];function Ke(){var e=Ge;Ge=[],f(e)}function qe(e){if(Ge.length===0&&!bt){var t=Ge;queueMicrotask(()=>{t===Ge&&Ke()})}Ge.push(e)}function Je(){for(;Ge.length>0;)Ke()}var Ye=~(g|_|h);function A(e,t){e.f=e.f&Ye|t}function Xe(e){e.f&512||e.deps===null?A(e,h):A(e,_)}function Ze(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),A(e,h)}var Qe=!1;function $e(){Qe||(Qe=!0,document.addEventListener(`reset`,e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(let t of e.target.elements)t[fe]?.()})},{capture:!0}))}function et(e){var t=H,n=W;U(null),Fn(null);try{return e()}finally{U(t),Fn(n)}}function tt(e,t,n,r=n){e.addEventListener(t,()=>et(n));let i=e[fe];e[fe]=i?()=>{i(),r(!0)}:()=>r(!0),$e()}function nt(e,t,n,r){let i=We()?ot:ut;var a=e.filter(e=>!e.settled),o=t.map(i);if(n.length===0&&a.length===0){r(o);return}var s=W,c=rt(),l=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function u(e){if(!(s.f&16384)){c();try{r([...o,...e])}catch(e){sn(e,s)}it()}}var d=at();if(n.length===0){l.then(()=>u([])).finally(d);return}function f(){Promise.all(n.map(e=>ct(e))).then(u).catch(e=>sn(e,s)).finally(d)}l?l.then(()=>{c(),f(),it()}):f()}function rt(){var e=W,t=H,n=k,r=j;return function(i=!0){Fn(e),U(t),Be(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function it(e=!0){Fn(null),U(null),Be(null),e&&j?.deactivate()}function at(){var e=W,t=e.b,n=j,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function ot(e){var t=2|g;return W!==null&&(W.f|=C),{ctx:k,deps:null,effects:null,equals:Ee,f:t,fn:e,reactions:null,rv:0,v:w,wv:0,parent:W,ac:null}}var st=Symbol(`obsolete`);function ct(e,t,n){let r=W;r===null&&Ae();var i=void 0,a=Lt(w),o=!H,s=new Set;return _n(()=>{var t=W,n=p();i=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==pe&&n.reject(e)}).finally(it)}catch(e){n.reject(e),it()}var c=j;if(o){if(t.f&32768)var l=at();if(r.b?.is_rendered())c.async_deriveds.get(t)?.reject(st);else for(let e of s.values())e.reject(st);s.add(n),c.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),s.delete(n),t!==st&&(c.activate(),t?(a.f|=ie,Vt(a,t)):(a.f&8388608&&(a.f^=ie),Vt(a,e)),c.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),fn(()=>{for(let e of s)e.reject(st)}),new Promise(e=>{function t(n){function r(){n===i?e(a):t(i)}n.then(r,r)}t(i)})}function lt(e){let t=ot(e);return Ln(t),t}function ut(e){let t=ot(e);return t.equals=Oe,t}function dt(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(pe),t.ac=null}),t.fn!==null&&(t.teardown=d),Yn(t,0),xn(t))}function ht(e){if(e.effects!==null)for(let t of e.effects)t.teardown&&t.fn!==null&&Xn(t)}var gt=null,j=null,_t=null,vt=null,yt=null,bt=!1,xt=!1,St=null,Ct=null,wt=0,Tt=1,Et=class e{id=Tt++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){gt===null?gt=this:(gt.#n=this,this.#t=gt),gt=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)A(r,g),t(r);for(r of n.m)A(r,_),t(r)}this.#p.add(e)}#g(){var e=[];for(let i of this.#c)if(!(i.f&16384||!(i.f&6144))){for(var t=i,n=!1;t.parent!==null;){t=t.parent;var r=t.f;if(r&96){if(!(r&1024)){n=!0;break}t.f^=h}}n||e.push(t)}return this.#c=[],e}#_(){this.#e=!0;for(let e of this.#u)this.#d.delete(e),A(e,g),this.schedule(e);for(let e of this.#d)A(e,_),this.schedule(e);this.apply();for(var t=St=[],n=[],r=Ct=[];this.#c.length>0;){wt++>1e3&&(this.#S(),Ot());for(let e of this.#g())try{this.#v(e,t,n)}catch(t){throw Nt(e),this.#h()||this.discard(),t}}if(j=null,r.length>0){var i=e.ensure();for(let e of r)i.schedule(e)}if(St=null,Ct=null,this.#h()){this.#x(n),this.#x(t);for(let[e,t]of this.#f)Mt(e,t);r.length>0&&j.#_();return}let a=this.#y();if(a)this.#x(n),this.#x(t),a.#b(this);else{this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),_t=this,At(n),At(t),_t=null,this.#s?.resolve();var o=j;if(this.#a===0&&(this.#c.length===0||o!==null)&&this.#S(),this.#c.length>0){if(o!==null){for(let e of this.#c)o.#c.push(e);this.#c=[]}else o=this}o!==null&&(Ft.clear(),o.#_())}}#v(e,t,n){e.f^=h;for(var r=e.first;r!==null;){var i=r.f,a=!!(i&96);if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=h:i&4?t.push(r):Wn(r)&&(i&16&&this.#d.add(r),Xn(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#y(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#b(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}e.async_deriveds.clear(),this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null&&!(e.f&2&&!(e.f&6144)))for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),A(i,g),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#S(),j=this,this.#_()}#x(e){for(var t=0;t{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=p()).promise}static ensure(){if(j===null){let t=j=new e;!xt&&!bt&&qe(()=>{t.#e||t.flush()})}return j}apply(){vt=null}schedule(e){yt=e,e.b?.is_pending&&e.f&16777228&&!(e.f&32768)?e.b.defer_effect(e):this.#c.push(e)}#S(){if(this.linked){var e=this.#t,t=this.#n;e===null||(e.#n=t),t===null?gt=e:t.#t=e,this.linked=!1}}};function Dt(e){var t=bt;bt=!0;try{var n;for(e&&(j!==null&&!j.is_fork&&j.flush(),n=e());;){if(Je(),j===null)return n;j.flush()}}finally{bt=t}}function Ot(){try{Fe()}catch(e){sn(e,yt)}}var kt=null;function At(e){var t=e.length;if(t!==0){for(var n=0;n0)){Ft.clear();for(let e of kt){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)kt.has(n)&&(kt.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||Xn(n)}}kt.clear()}}kt=null}}function jt(e){j.schedule(e)}function Mt(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),A(e,h);for(var n=e.first;n!==null;)Mt(n,t),n=n.next}}function Nt(e){A(e,h);for(var t=e.first;t!==null;)Nt(t),t=t.next}var Pt=new Set,Ft=new Map,It=!1;function Lt(e,t){return{f:0,v:e,reactions:null,equals:Ee,rv:0,wv:0}}function M(e,t){let n=Lt(e,t);return Ln(n),n}function Rt(e,t=!1,n=!0){let r=Lt(e);return t||(r.equals=Oe),r}function N(e,t,n=!1){return H!==null&&(!Pn||H.f&131072)&&We()&&H.f&4325394&&(In===null||!In.has(e))&&Re(),Vt(e,n?Gt(t):t,Ct)}var zt=null,Bt=0;function Vt(e,t,n=null){if(!e.equals(t)){Mn?Ft.set(e,t):Ft.has(e)||Ft.set(e,e.v);var r=Et.ensure();if(r.capture(e,t),e.f&2){let t=e;e.f&2048&&ft(t),vt===null&&Xe(t)}e.wv=Un(),zt=null,Bt=0,Wt(e,g,n),zt=null,We()&&W!==null&&W.f&1024&&!(W.f&96)&&(q===null?Rn([e]):q.push(e)),!r.is_fork&&Pt.size>0&&!It&&Ht()}return t}function Ht(){It=!1;for(let e of Pt){e.f&1024&&A(e,_);let t;try{t=Wn(e)}catch{t=!0}t&&Xn(e)}Pt.clear()}function Ut(e){N(e,e.v+1)}function Wt(e,t,n){var r=e.reactions;if(r!==null){var i=We(),a=r.length;if(Bt+=a,Bt>1e5&&zt===null&&(zt=new Set),zt!==null){if(zt.has(e))return;zt.add(e)}for(var o=0;o{if(Vn===d)return e();var t=H,n=Vn;U(null),Hn(d);var r=e();return U(t),Hn(n),r};return i&&r.set(`length`,M(t.length,u)),new Proxy(t,{defineProperty(e,t,n){`value`in n&&n.configurable!==!1&&n.enumerable!==!1&&n.writable!==!1||Ie();var i=r.get(t);return i===void 0?f(()=>{var e=M(n.value,u);return r.set(t,e),e}):N(i,n.value,!0),!0},deleteProperty(e,t){var n=r.get(t);if(n===void 0){if(t in e){let e=f(()=>M(w,u));r.set(t,e),Ut(o)}}else N(n,w),Ut(o);return!0},get(e,n,i){if(n===ae)return t;var o=r.get(n),s=n in e;if(o===void 0&&(!s||a(e,n)?.writable)&&(o=f(()=>M(Gt(s?e[n]:w),u)),r.set(n,o)),o!==void 0){var c=J(o);return c===w?void 0:c}return Reflect.get(e,n,i)},getOwnPropertyDescriptor(e,t){this.has?.(e,t);var n=Reflect.getOwnPropertyDescriptor(e,t),i=r.get(t);if(i!==void 0){var a=J(i);if(a===w)return;if(n&&`value`in n)n.value=a;else return{enumerable:!0,configurable:!0,value:a,writable:!0}}return n},has(e,t){if(t===ae)return!0;var n=r.get(t),i=n!==void 0&&n.v!==w||Reflect.has(e,t);return(n!==void 0||W!==null&&(!i||a(e,t)?.writable))&&(n===void 0&&(n=f(()=>M(i?Gt(e[t]):w,u)),r.set(t,n)),J(n)===w)?!1:i},set(e,t,n,s){var c=r.get(t),l=t in e;if(i&&t===`length`)for(var d=n;dM(w,u)),r.set(d+``,p)):N(p,w)}if(c===void 0)(!l||a(e,t)?.writable)&&(c=f(()=>M(void 0,u)),N(c,Gt(n)),r.set(t,c));else{l=c.v!==w;var m=f(()=>Gt(n));N(c,m)}var h=Reflect.getOwnPropertyDescriptor(e,t);if(h?.set&&h.set.call(s,n),!l){if(i&&typeof t==`string`){var g=r.get(`length`),_=Number(t);Number.isInteger(_)&&_>=g.v&&N(g,_+1)}Ut(o)}return!0},ownKeys(e){J(o);var t=Reflect.ownKeys(e).filter(e=>{var t=r.get(e);return t===void 0||t.v!==w});for(var[n,i]of r)i.v!==w&&!(n in e)&&t.push(n);return t},setPrototypeOf(){Le()}})}function Kt(e){try{if(typeof e==`object`&&e&&ae in e)return e[ae]}catch{}return e}function qt(e,t){return Object.is(Kt(e),Kt(t))}var Jt,Yt,Xt,Zt;function Qt(){if(Jt===void 0){Jt=window,Yt=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;Xt=a(t,`firstChild`).get,Zt=a(t,`nextSibling`).get,u(e)&&(e[le]=void 0,e[ce]=null,e[ue]=void 0,e.__e=void 0),u(n)&&(n[de]=void 0)}}function P(e=``){return document.createTextNode(e)}function $t(e){return Xt.call(e)}function en(e){return Zt.call(e)}function F(e,t){if(!T)return $t(e);var n=$t(E);if(n===null)n=E.appendChild(P());else if(t&&n.nodeType!==3){var r=P();return n?.before(r),D(r),r}return t&&an(n),D(n),n}function I(e,t=!1){if(!T){var n=$t(e);return n instanceof Comment&&n.data===``?en(n):n}if(t){if(E?.nodeType!==3){var r=P();return E?.before(r),D(r),r}an(E)}return E}function L(e,t=!1){if(!T)return $t(e);var n=F(e,t);return O(e),n}function R(e,t=1,n=!1){let r=T?E:e;for(var i;t--;)i=r,r=en(r);if(!T)return r;if(n){if(r?.nodeType!==3){var a=P();return r===null?i?.after(a):r.before(a),D(a),a}an(r)}return D(r),r}function tn(e){e.textContent=``}function nn(){return!1}function rn(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function an(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function on(e){var t=W;if(t===null)return H.f|=ie,e;if(!(t.f&32768)&&!(t.f&4))throw e;sn(e,t)}function sn(e,t){if(!(t!==null&&t.f&16384)){for(;t!==null;){if(t.f&128&&!(t.f&33570816)){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}}function cn(e){W===null&&(H===null&&Pe(e),Ne()),Mn&&Me(e)}function ln(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function un(e,t){var n=W;n!==null&&n.f&8192&&(e|=v);var r={ctx:k,deps:null,nodes:null,f:e|g|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};j?.register_created_effect(r);var i=r;if(e&4)St===null?Et.ensure().schedule(r):St.push(r);else if(t!==null){try{Xn(r)}catch(e){throw V(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=S))}if(i!==null&&(i.parent=n,n!==null&&ln(i,n),H!==null&&H.f&2&&!(e&64))){var a=H;(a.effects??=[]).push(i)}return r}function dn(){return H!==null&&!Pn}function fn(e){let t=un(8,null);return A(t,h),t.teardown=e,t}function pn(e){cn(`$effect`);var t=W.f;if(!H&&t&32&&k!==null&&!k.i){var n=k;(n.e??=[]).push(e)}else return mn(e)}function mn(e){return un(4|ee,e)}function hn(e){Et.ensure();let t=un(64|C,e);return(e={})=>new Promise(n=>{e.outro?Tn(t,()=>{V(t),n(void 0)}):(V(t),n(void 0))})}function gn(e){return un(4,e)}function _n(e){return un(re|C,e)}function vn(e,t=0){return un(8|t,e)}function z(e,t=[],n=[],r=[]){nt(r,t,n,t=>{un(8,()=>{e(...t.map(J))})})}function yn(e,t=0){return un(16|t,e)}function B(e){return un(32|C,e)}function bn(e){var t=e.teardown;if(t!==null){let n=Mn,r=H;Nn(!0),U(null);try{t.call(null)}catch(t){sn(t,e.parent)}finally{Nn(n),U(r)}}}function xn(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&&et(()=>{e.abort(pe)});var r=n.next;n.f&64?n.parent=null:V(n,t),n=r}}function Sn(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||V(t),t=n}}function V(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(Cn(e.nodes.start,e.nodes.end),n=!0),e.f|=x,xn(e,t&&!n),Yn(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();bn(e),e.f^=x,e.f|=y;var i=e.parent;i!==null&&i.first!==null&&wn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Cn(e,t){for(;e!==null;){var n=e===t?null:en(e);e.remove(),e=n}}function wn(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Tn(e,t,n=!0){var r=[];e.f|=256,En(e,r,!0);var i=()=>{n&&V(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function En(e,t,n){if(!(e.f&8192)){e.f^=v;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=!!(i.f&65536)||!!(i.f&32)&&!!(e.f&16);En(i,t,o?n:!1)}i=a}}}function Dn(e){e.f&=-257,On(e,!0)}function On(e,t){if(!(e.f&256)&&e.f&8192){e.f^=v,e.f&1024||(A(e,g),Et.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=!!(n.f&65536)||!!(n.f&32);On(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function kn(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:en(n);t.append(n),n=i}}var An=null,jn=!1,Mn=!1;function Nn(e){Mn=e}var H=null,Pn=!1;function U(e){H=e}var W=null;function Fn(e){W=e}var In=null;function Ln(e){H!==null&&(H.f&2097152||H.f&2)&&(In??=new Set).add(e)}var G=null,K=0,q=null;function Rn(e){q=e}var zn=1,Bn=0,Vn=Bn;function Hn(e){Vn=e}function Un(){return++zn}function Wn(e){var t=e.f;if(t&2048)return!0;if(t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&vt===null&&A(e,h)}return!1}function Gn(e,t,n=!0){var r=e.reactions;if(r!==null&&!(In!==null&&In.has(e)))for(var i=0;i{e.ac.abort(pe)}),e.ac=null);try{e.f|=ne;var u=e.fn,d=u();e.f|=b;var f=qn(e);if(We()&&q!==null&&!Pn&&f!==null&&!(e.f&6146))for(var p=0;p0)for(t.length=K+G.length,r=0;r{s.ac.abort(pe),s.ac=null,A(s,g)}),mt(s),Yn(s,0)}}function Yn(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?(i.__removed=!1,qe(()=>{i.__removed||t.addEventListener(e,i,r)})):t.addEventListener(e,i,r),i}function sr(e,t,n,r,i){var a={capture:r,passive:i},o=or(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&fn(()=>{o.__removed=!0,t.removeEventListener(e,o,a)})}function cr(e,t,n){(t[rr]??={})[e]=n}function lr(e){for(var t=0;t{dr=!1,ur=null}));var s=0,c=ur===e&&e[rr];if(c){var l=a.indexOf(c);if(l!==-1&&(t===document||t===window)){e[rr]=t;return}var u=a.indexOf(t);if(u===-1)return;l<=u&&(s=l)}if(o=a[s]||e.target,o!==t){i(e,`currentTarget`,{configurable:!0,get(){return o||n}});var d=H,f=W;U(null),Fn(null);try{for(var p,m=[];o!==null&&o!==t;){try{var h=o[rr]?.[r];h==null||o.disabled&&e.target!==o||h.call(o,e)}catch(e){p?m.push(e):p=e}if(e.cancelBubble)break;s++,o=s{throw e});throw p}}finally{e[rr]=t,delete e.currentTarget,U(d),Fn(f)}}}var pr=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function mr(e){return pr?.createHTML(e)??e}function hr(e){var t=rn(`template`);return t.innerHTML=mr(e.replaceAll(``,``)),t.content}function gr(e,t){var n=W;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function Y(e,t){var n=!!(t&1),r=!!(t&2),i,a=!e.startsWith(``);return()=>{if(T)return gr(E,null),E;i===void 0&&(i=hr(a?e:``+e),n||(i=$t(i)));var t=r||Yt?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=$t(t),s=t.lastChild;gr(o,s)}else gr(t,t);return t}}function _r(e=``){if(!T){var t=P(e+``);return gr(t,t),t}var n=E;return n.nodeType===3?an(n):(n.before(n=P()),D(n)),gr(n,n),n}function vr(){if(T)return gr(E,null),E;var e=document.createDocumentFragment(),t=document.createComment(``),n=P();return e.append(t,n),gr(t,n),e}function X(e,t){if(T){var n=W;n.f&32768&&n.nodes.end!==null||(n.nodes.end=E),Se()}else e!==null&&e.before(t)}function yr(e){let t=0,n=Lt(0),r;return()=>{dn()&&(J(n),vn(()=>(t===0&&(r=er(()=>e(()=>Ut(n)))),t+=1,()=>{qe(()=>{--t,t===0&&(r?.(),r=void 0,Ut(n))})})))}}var br=S|C;function xr(e,t,n,r){new Sr(e,t,n,r)}var Sr=class{parent;is_pending=!1;transform_error;#e;#t=T?E:null;#n;#r;#i;#a=null;#o=null;#s=null;#c=null;#l=0;#u=0;#d=!1;#f=new Set;#p=new Set;#m=null;#h=yr(()=>(this.#m=Lt(this.#l),()=>{this.#m=null}));constructor(e,t,n,r){this.#e=e,this.#n=t,this.#r=e=>{var t=W;t.b=this,t.f|=128,n(e)},this.parent=W.b,this.transform_error=r??this.parent?.transform_error??(e=>e),this.#i=yn(()=>{if(T){let e=this.#t;Se();let t=e.data===`[!`;if(e.data.startsWith(`[?`)){let t=JSON.parse(e.data.slice(2));this.#_(t)}else t?this.#y():this.#g()}else this.#b()},br),T&&(this.#e=E)}#g(){try{this.#a=B(()=>this.#r(this.#e))}catch(e){this.error(e)}}#_(e){let t=this.#n.failed,{reset:n,invoke_onerror:r}=this.#v(e);qe(r),t&&(this.#s=B(()=>{t(this.#e,()=>e,()=>n)}))}#v(e){var t=!1,n=!1;let r=()=>{t?be():(t=!0,n&&ze(),this.#s!==null&&Tn(this.#s,()=>{this.#s=null}),this.#S(()=>{this.#b()}))};return{reset:r,invoke_onerror:()=>{try{n=!0,this.#n.onerror?.(e,r),n=!1}catch(e){sn(e,this.#i&&this.#i.parent)}}}}#y(){let e=this.#n.pending;e&&(this.is_pending=!0,this.#o=B(()=>e(this.#e)),qe(()=>{var e=this.#c=document.createDocumentFragment(),t=P(),n=!1;e.append(t),this.#a=this.#S(()=>{try{return B(()=>this.#r(t))}catch(e){try{this.error(e),n=!0}catch(e){sn(e,this.#i.parent)}return null}}),this.#a===null?(this.#c=null,n&&this.#x(j)):this.#u===0&&(this.#e.before(e),this.#c=null,Tn(this.#o,()=>{this.#o=null}),this.#x(j))}))}#b(){try{if(this.is_pending=this.has_pending_snippet(),this.#u=0,this.#l=0,this.#a=B(()=>{this.#r(this.#e)}),this.#u>0){var e=this.#c=document.createDocumentFragment();kn(this.#a,e);let t=this.#n.pending;this.#o=B(()=>t(this.#e))}else this.#x(j)}catch(e){this.error(e)}}#x(e){this.is_pending=!1,e.transfer_effects(this.#f,this.#p)}defer_effect(e){Ze(e,this.#f,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#n.pending}#S(e){var t=W,n=H,r=k;Fn(this.#i),U(this.#i),Be(this.#i.ctx);try{return Et.ensure(),e()}finally{Fn(t),U(n),Be(r)}}#C(e,t){this.has_pending_snippet()?(this.#u+=e,this.#u===0&&(this.#x(t),this.#o&&Tn(this.#o,()=>{this.#o=null}),this.#c&&=(this.#e.before(this.#c),null))):this.parent&&this.parent.#C(e,t)}update_pending_count(e,t){this.#C(e,t),this.#l+=e,!(!this.#m||this.#d)&&(this.#d=!0,qe(()=>{this.#d=!1,this.#m&&Vt(this.#m,this.#l)}))}get_effect_pending(){return this.#h(),J(this.#m)}error(e){if(!this.#n.onerror&&!this.#n.failed)throw e;j?.is_fork?(this.#a&&j.skip_effect(this.#a),this.#o&&j.skip_effect(this.#o),this.#s&&j.skip_effect(this.#s),j.oncommit(()=>{this.#w(e)})):this.#w(e)}#w(e){this.#a&&=(V(this.#a),null),this.#o&&=(V(this.#o),null),this.#s&&=(V(this.#s),null),T&&(D(this.#t),Ce(),D(we()));let t=this.#n.failed,n=e=>{let{reset:n,invoke_onerror:r}=this.#v(e);r(),t&&(this.#s=this.#S(()=>{try{return B(()=>{var r=W;r.b=this,r.f|=128,t(this.#e,()=>e,()=>n)})}catch(e){return sn(e,this.#i.parent),null}}))};qe(()=>{var t;try{t=this.transform_error(e)}catch(e){sn(e,this.#i&&this.#i.parent);return}typeof t==`object`&&t&&typeof t.then==`function`?t.then(n,e=>sn(e,this.#i&&this.#i.parent)):n(t)})}};function Z(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[de]??=e.nodeValue)&&(e[de]=n,e.nodeValue=`${n}`)}function Cr(e,t){return Tr(e,t)}var wr=new Map;function Tr(e,{target:t,anchor:n,props:i={},events:a,context:o,intro:s=!0,transformError:c}){Qt();var l=void 0,u=hn(()=>{var s=n??t.appendChild(P());xr(s,{pending:()=>{}},t=>{Ve({});var n=k;if(o&&(n.c=o),a&&(i.$$events=a),T&&gr(t,null),l=e(t,i)||Ue(),T&&(W.nodes.end=E,E===null||E.nodeType!==8||E.data!==`]`))throw ve(),he;He()},c);var u=new Set,d=e=>{for(var n=0;n{for(var e of u)for(let n of[t,document]){var r=wr.get(n),i=r.get(e);--i==0?(n.removeEventListener(e,fr),r.delete(e),r.size===0&&wr.delete(n)):r.set(e,i)}ar.delete(d),s!==n&&s.parentNode?.removeChild(s)}});return Er.set(l,u),l}var Er=new WeakMap,Dr=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)Dn(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(Dn(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(V(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();kn(r,t),t.append(P()),this.#n.set(e,{effect:r,fragment:t})}else V(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),Tn(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(V(n.effect),this.#n.delete(e))};ensure(e,t){var n=j,r=nn();if(t&&!this.#t.has(e)&&!this.#n.has(e)){if(r){var i=document.createDocumentFragment(),a=P();i.append(a),this.#n.set(e,{effect:B(()=>t(a)),fragment:i})}else this.#t.set(e,B(()=>t(this.anchor)))}if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else T&&(this.anchor=E),this.#a(n)}};function Q(e,t,n=!1){var r;T&&(r=E,Se());var i=new Dr(e),a=n?S:0;function o(e,t){if(T){var n=Te(r);if(e!==parseInt(n.substring(1))){var a=we();D(a),i.anchor=a,xe(!1),i.ensure(e,t),xe(!0);return}}i.ensure(e,t)}yn(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}var Or=Symbol(`NaN`);function kr(e,t,n){T&&Se();var r=new Dr(e),i=!We();yn(()=>{var e=t();e!==e&&(e=Or),i&&typeof e==`object`&&e&&(e={}),r.ensure(e,n)})}function Ar(e,t){return t}function jr(e,t,n){for(var i=[],a=t.length,o,s=t.length,c=0;c{if(o){if(o.pending.delete(n),o.done.add(n),o.pending.size===0){var t=e.outrogroups;Mr(e,r(o.done)),t.delete(o),t.size===0&&(e.outrogroups=null)}}else--s},!1)}if(s===0){var l=i.length===0&&n!==null&&e.pending.size===0;if(l){var u=n,d=u.parentNode;tn(d),d.append(u),e.items.clear()}Mr(e,t,!l)}else o={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(o)}function Mr(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(let t of e.pending.values())for(let n of t)r.add(e.items.get(n).e)}for(var i=0;i{var t=i();return e(t)?t:t==null?[]:r(t)}),p,m=new Map,h=!0;function g(e){v.effect.f&16384||(v.pending.delete(e),v.fallback=d,Fr(v,p,c,n,a),d!==null&&(p.length===0?d.f&33554432?(d.f^=te,Lr(d,null,c)):Dn(d):Tn(d,()=>{d=null})))}function _(e){v.pending.delete(e)}var v={effect:yn(()=>{p=J(f);var e=p.length;let t=!1;T&&Te(c)===`[!`!=(e===0)&&(c=we(),D(c),xe(!1),t=!0);for(var r=new Set,u=j,v=nn(),y=0;ys(c)):(d=B(()=>s(Nr??=P())),d.f|=te)),e>r.size&&je(``,``,``),T&&e>0&&D(we()),!h){if(m.set(u,r),v){for(let[e,t]of l)r.has(e)||u.skip_effect(t.e);u.oncommit(g),u.ondiscard(_)}else g(u)}t&&xe(!0),J(f)}),flags:n,items:l,pending:m,outrogroups:null,fallback:d};h=!1,T&&(c=E)}function Pr(e){for(;e!==null&&!(e.f&32);)e=e.next;return e}function Fr(e,t,n,i,a){var o=!!(i&8),s=t.length,c=e.items,l=Pr(e.effect.first),u,d=null,f,p=[],m=[],h,g,_,v;if(o)for(v=0;v0){var re=i&4&&s===0?n:null;if(o){for(v=0;v{if(f!==void 0)for(_ of f)_.nodes?.a?.apply()})}function Ir(e,t,n,r,i,a,o,s){var c=o&1?o&16?Lt(n):Rt(n,!1,!1):null,l=o&2?Lt(i):null;return{v:c,i:l,e:B(()=>(a(t,c??n,l??i,s),()=>{e.delete(r)}))}}function Lr(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,a=t&&!(t.f&33554432)?t.nodes.start:n;r!==null;){var o=en(r);if(a.before(r),r===i)return;r=o}}function Rr(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}var zr=[...` +\r\f\xA0\v`];function Br(e,t,n){var r=e==null?``:``+e;if(t&&(r=r?r+` `+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+` `+i:i;else if(r.length)for(var a=i.length,o=0;(o=r.indexOf(i,o))>=0;){var s=o+a;(o===0||zr.includes(r[o-1]))&&(s===r.length||zr.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function Vr(e,t,n,r,i,a){var o=e[le];if(T||o!==n||o===void 0){var s=Br(n,r,a);T&&s===e.getAttribute(`class`)||(s==null?e.removeAttribute(`class`):t?e.className=s:e.setAttribute(`class`,s)),e[le]=n}else if(a&&i!==a)for(var c in a){var l=!!a[c];(i==null||l!==!!i[c])&&e.classList.toggle(c,l)}return a}function Hr(e,t){t?e.hasAttribute(`selected`)||e.setAttribute(`selected`,``):e.removeAttribute(`selected`)}function Ur(t,n){var r=t.__defaultValue,i=t.multiple,a=i?r??[]:null;if(!i||e(a)){var o=t.selectedIndex,s=n&&i?new Set(t.selectedOptions):null;for(var c of t.options){var l=qr(c);Hr(c,i?a.includes(l):qt(l,r))}if(n){if(s!==null)for(c of t.options){var u=s.has(c);c.selected!==u&&(c.selected=u)}else t.selectedIndex!==o&&(t.selectedIndex=o)}}}function Wr(t,n,r=!1){if(t.multiple){if(n==null)return;if(!e(n))return ye();for(var i of t.options)i.selected=n.includes(qr(i))}else{for(i of t.options)if(qt(qr(i),n)){i.selected=!0;return}r&&n===void 0||(t.selectedIndex=-1)}}function Gr(e){var t=new MutationObserver(t=>{t.every(Jr)||(`__defaultValue`in e&&Ur(e,!1),`__value`in e&&Wr(e,e.__value))});t.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`value`]}),fn(()=>{t.disconnect()})}function Kr(e,t,n=t){var r=new WeakSet,i=!0;tt(e,`change`,t=>{var i=t?`[selected]`:`:checked`,a;if(e.multiple)a=[].map.call(e.querySelectorAll(i),qr);else{var o=e.querySelector(i)??e.querySelector(`option:not([disabled])`);a=o&&qr(o)}n(a),e.__value=a,j!==null&&r.add(j)}),gn(()=>{var a=t();if(e===document.activeElement){var o=j;if(r.has(o))return}if(Wr(e,a,i),i&&a===void 0){var s=e.querySelector(`:checked`);s!==null&&(a=qr(s),n(a))}e.__value=a,i=!1})}function qr(e){return`__value`in e?e.__value:e.value}function Jr(e){if(e.target.closest(`selectedcontent`)!==null)return!0;if(e.type===`childList`){var t=[...e.addedNodes,...e.removedNodes];return t.length>0&&t.every(e=>e.nodeName===`SELECTEDCONTENT`)}return!1}var Yr=Symbol(`is custom element`),Xr=Symbol(`is html`),Zr=me?`link`:`LINK`;function Qr(e){if(T){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute(`value`)){var n=e.value;$r(e,`value`,null),e.value=n}if(e.hasAttribute(`checked`)){var r=e.checked;$r(e,`checked`,null),e.checked=r}}};e[fe]=n,qe(n),$e()}}function $r(e,t,n,r){var i=ei(e);T&&(i[t]=e.getAttribute(t),t===`src`||t===`srcset`||t===`href`&&e.nodeName===Zr)||i[t]!==(i[t]=n)&&(t===`loading`&&(e[se]=n),n==null?e.removeAttribute(t):typeof n!=`string`&&ni(e).has(t)?e[t]=n:e.setAttribute(t,n))}function ei(e){return e[ce]??={[Yr]:e.nodeName.includes(`-`),[Xr]:e.namespaceURI===ge}}var ti=new Map;function ni(e){var t=e.getAttribute(`is`)||e.nodeName,n=ti.get(t);if(n)return n;ti.set(t,n=new Set);for(var r,i=e,a=Element.prototype;a!==i;){for(var s in r=o(i),r)r[s].set&&s!==`innerHTML`&&s!==`textContent`&&s!==`innerText`&&n.add(s);i=l(i)}return n}function ri(e,t,n=t){var r=new WeakSet;tt(e,`input`,async i=>{var a=i?e.defaultValue:e.value;if(a=ai(e)?oi(a):a,n(a),j!==null&&r.add(j),await Zn(),a!==(a=t())){var o=e.selectionStart,s=e.selectionEnd,c=e.value.length;if(e.value=a??``,s!==null){var l=e.value.length;o===s&&s===c&&l>c?(e.selectionStart=l,e.selectionEnd=l):(e.selectionStart=o,e.selectionEnd=Math.min(s,l))}}}),(T&&e.defaultValue!==e.value||er(t)==null&&e.value)&&(n(ai(e)?oi(e.value):e.value),j!==null&&r.add(j)),vn(()=>{var n=t();if(e===document.activeElement){var i=j;if(r.has(i))return}ai(e)&&n===oi(e.value)||(e.type!==`date`||n||e.value)&&n!==e.value&&(e.value=n??``)})}function ii(e,t,n=t){tt(e,`change`,t=>{n(t?e.defaultChecked:e.checked)}),(T&&e.defaultChecked!==e.checked||er(t)==null)&&n(e.checked),vn(()=>{e.checked=!!t()})}function ai(e){var t=e.type;return t===`number`||t===`range`}function oi(e){return e===``?null:+e}function si(e){k===null&&ke(`onMount`),pn(()=>{let t=er(e);if(typeof t==`function`)return t})}typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);var ci=``,li=class extends Error{constructor(e,t){super(t),this.status=e}};async function ui(e,t,n){let r={Accept:`application/json`};e!==`GET`&&(r[`Content-Type`]=`application/json`,r[`X-Rowbit-Token`]=ci);let i=await fetch(t,{method:e,headers:r,credentials:`same-origin`,body:n===void 0?void 0:JSON.stringify(n)});if(i.status===401)throw window.location.assign(`/login`),new li(401,`Signed out`);let a=await i.json().catch(()=>({}));if(!i.ok)throw new li(i.status,a.error||`HTTP ${i.status}`);return a}async function di(){let e=await ui(`GET`,`/api/session`);return ci=e.token,e}var fi=(e={})=>ui(`GET`,`/api/changes?${new URLSearchParams(Object.entries(e).filter(([,e])=>e))}`),pi=(e,t=0)=>ui(`GET`,`/api/changes/${e}?offset=${t}`),mi=(e,t,n)=>ui(`POST`,`/api/changes/${e}/approve`,{review:t,rows:n}),hi=(e,t)=>ui(`POST`,`/api/changes/${e}/reject`,{reason:t}),gi=e=>ui(`POST`,`/api/changes/${e}/undo`,{}),_i=()=>ui(`GET`,`/api/verify`),vi=()=>ui(`GET`,`/api/deliveries`),yi=()=>ui(`GET`,`/api/setup`),bi=()=>ui(`POST`,`/api/logout`,{});function xi(e,t,n=`${t}s`){return`${e.toLocaleString(`en`)} ${e===1?t:n}`}function Si(e){let t=Math.max(0,Math.round((Date.now()-e)/1e3));return t<60?`${t}s ago`:t<3600?`${Math.round(t/60)} min ago`:t<86400?`${Math.round(t/3600)} h ago`:`${Math.round(t/86400)} d ago`}function Ci(e){return new Date(e).toLocaleString()}var wi={insert:`inserts`,update:`updates`,delete:`deletes`,ddl:`changes the schema`},Ti=Y(` `);function Ei(e,t){var n=Ti(),r=L(n,!0);z(()=>{Vr(n,1,`state ${t.state??``}`),Z(r,t.state)}),X(e,n)}var Di=Y(`-`),Oi=Y(`NULL`),ki=Y(` `),Ai=Y(` `),ji=Y(` `);function Mi(e,t){Ve(t,!0);var n=vr(),r=I(n),i=e=>{X(e,Di())},a=e=>{X(e,Oi())},o=e=>{var n=ki(),r=L(n,!0);z(()=>Z(r,t.value.text)),X(e,n)},s=e=>{var n=Ai(),r=L(n,!0);z(()=>Z(r,t.value.text)),X(e,n)},c=e=>{var n=ji(),r=L(n);z(()=>Z(r,`"${t.value.text??``}"`)),X(e,n)};Q(r,e=>{t.value==null?e(i):t.value.kind===`null`?e(a,1):t.value.kind===`number`?e(o,2):t.value.kind===`binary`?e(s,3):e(c,-1)}),X(e,n),He()}var Ni=Y(``),Pi=Y(`

`),Fi=Y(`

`),Ii=Y(`

Runs this schema change, which can't be undone:

 
`,1),Li=Y(``,1),Ri=Y(`new`),zi=Y(` `),Bi=Y(`

`),Vi=Y(`

in

RowColumnBeforeAfter
`,1),Hi=Y(``),Ui=Y(`

`,1),Wi=Y(`
Restores every row as it was, unless one changed since.
`),Gi=Y(` `),Ki=Y(`

 

History

WhenEventStateByDetail
`,1),qi=Y(`

Back to the queue

`,1);function Ji(e,t){Ve(t,!0);let n=M(null),r=M(``),i=M(!1),a=M(!1),o=M(``),s=M(``);async function c(e=0){try{N(n,await pi(t.id,e),!0),N(r,``)}catch(e){N(r,e.message,!0)}}pn(()=>{c()});async function l(e){N(i,!0);try{N(n,await e(),!0),N(r,``)}catch(e){N(r,e.message,!0)}finally{N(i,!1)}}let u=lt(()=>J(n)?.preview?.total_rows??0),d=lt(()=>J(n)?.preview?J(u)===1?`Approve and apply 1 row`:`Approve and apply all ${J(u).toLocaleString(`en`)} rows`:`Approve and run`),f=lt(()=>!J(n)?.confirm_rows||Number(J(o))===J(u)),p=lt(()=>J(n)?.preview?Math.floor(J(n).preview.offset/50):0),m=lt(()=>Math.ceil(J(u)/50));var h=qi(),g=R(I(h),2),_=e=>{var t=Ni(),n=L(t,!0);z(()=>Z(n,J(r))),X(e,t)};Q(g,e=>{J(r)&&e(_)});var v=R(g,2),y=e=>{var t=Ki(),r=I(t),h=F(r),g=F(h),_=L(g);Ei(R(g,2),{get state(){return J(n).state}}),O(h);var v=R(h,2),y=e=>{var t=Pi(),r=L(t,!0);z(()=>Z(r,J(n).reason)),X(e,t)};Q(v,e=>{J(n).reason&&e(y)});var b=R(v,2),x=e=>{var t=Fi(),r=L(t);z(()=>Z(r,`Shadow mode: ${J(n).shadowed.would??``} (${J(n).shadowed.by??``}). It was applied anyway.`)),X(e,t)};Q(b,e=>{J(n).shadowed&&e(x)});var S=R(b,2),C=L(S,!0),ee=R(S,2),te=e=>{var t=Ii(),r=L(R(I(t),2),!0);z(()=>Z(r,J(n).ddl)),X(e,t)},ne=e=>{var t=Vi(),r=I(t),i=F(r),o=R(i),s=L(o,!0),l=R(o,2),d=L(l,!0),f=R(l,2),h=F(f);Qr(h),Ce(),O(f),O(r);var g=R(r,2),_=F(g),v=R(F(_));$(v,21,()=>J(n).preview.rows,Ar,(e,t)=>{var n=vr();$(I(n),17,()=>J(t).cells.filter(e=>J(a)||e.changed),Ar,(e,n,r)=>{var i=zi();Vr(i,1,``,null,{},{first:r===0});var a=F(i),o=F(a),s=e=>{var n=vr(),r=I(n),i=e=>{var n=vr();$(I(n),17,()=>J(t).key,Ar,(e,t,n)=>{var r=Li(),i=I(r),a=e=>{X(e,_r(`,`))};Q(i,e=>{n>0&&e(a)}),Mi(R(i),{get value(){return J(t)}}),X(e,r)}),X(e,n)},a=e=>{X(e,Ri())};Q(r,e=>{J(t).key?e(i):e(a,-1)}),X(e,n)};Q(o,e=>{r===0&&e(s)}),O(a);var c=R(a),l=L(c,!0),u=R(c);let d;Mi(F(u),{get value(){return J(n).before}}),O(u);var f=R(u);let p;Mi(F(f),{get value(){return J(n).after}}),O(f),O(i),z(()=>{Z(l,J(n).column),d=Vr(u,1,`before`,null,d,{same:!J(n).changed}),p=Vr(f,1,`after`,null,p,{same:!J(n).changed})}),X(e,i)}),X(e,n)}),O(v),O(_),O(g);var y=R(g,2),b=e=>{var t=Bi(),r=F(t),i=R(r),a=R(i);O(t),z((e,t)=>{r.disabled=J(p)===0,Z(i,` Rows ${J(n).preview.offset+1} to ${e??``} of + ${t??``} `),a.disabled=J(p)+1>=J(m)},[()=>Math.min(J(n).preview.offset+50,J(u)),()=>J(u).toLocaleString(`en`)]),cr(`click`,r,()=>c((J(p)-1)*50)),cr(`click`,a,()=>c((J(p)+1)*50)),X(e,t)};Q(y,e=>{J(m)>1&&e(b)}),z(e=>{Z(i,`${wi[J(n).preview.kind]??``} `),Z(s,e),Z(d,J(n).preview.table)},[()=>xi(J(u),`row`)]),ii(h,()=>J(a),e=>N(a,e)),X(e,t)};Q(ee,e=>{J(n).ddl?e(te):J(n).preview&&e(ne,1)});var re=R(ee,2),ie=e=>{var t=Ui(),r=I(t),a=F(r),c=F(a),p=e=>{var t=Hi(),n=R(F(t));Qr(n),O(t),z(e=>$r(n,`placeholder`,e),[()=>String(J(u))]),ri(n,()=>J(o),e=>N(o,e)),X(e,t)};Q(c,e=>{J(n).confirm_rows&&e(p)});var m=R(c,2),h=L(m,!0);O(a);var g=R(a,2),_=F(g);Qr(_);var v=R(_,2);O(g),O(r);var y=L(R(r,2));z(e=>{m.disabled=J(i)||!J(f),Z(h,J(d)),v.disabled=J(i),Z(y,`Approving covers review ${e??``}: exactly the rows above.`)},[()=>J(n).review_hash?.slice(0,12)]),sr(`submit`,a,e=>{e.preventDefault(),l(()=>mi(J(n).id,J(n).review_hash,J(n).confirm_rows?Number(J(o)):void 0))}),sr(`submit`,g,e=>{e.preventDefault(),l(()=>hi(J(n).id,J(s)))}),ri(_,()=>J(s),e=>N(s,e)),X(e,t)};Q(re,e=>{J(n).state===`held`&&e(ie)});var ae=R(re,2),oe=e=>{var t=Wi(),r=F(t);Ce(2),O(t),z(()=>r.disabled=J(i)),cr(`click`,r,()=>l(()=>gi(J(n).id))),X(e,t)};Q(ae,e=>{J(n).undoable&&e(oe)}),O(r);var se=R(r,4),ce=R(F(se));$(ce,21,()=>J(n).history,e=>e.seq,(e,t)=>{var n=Gi(),r=F(n),i=L(r,!0),a=R(r),o=L(a,!0),s=R(a),c=L(s,!0),l=R(s),u=L(l,!0),d=L(R(l),!0);O(n),z(e=>{Z(i,e),Z(o,J(t).event),Z(c,J(t).to),Z(u,J(t).actor),Z(d,J(t).detail??``)},[()=>Ci(J(t).at_ms)]),X(e,n)}),O(ce),O(se),z(()=>{Z(_,`#${J(n).id??``} from ${J(n).agent??``}`),Z(C,J(n).statement)}),X(e,t)};Q(v,e=>{J(n)&&e(y)}),X(e,h),He()}lr([`click`]);var Yi=Y(`

`),Xi=Y(`

Nothing sent yet.

`),Zi=Y(` `),Qi=Y(`
ChangeTargetStateTriesLast errorQueued
`),$i=Y(`

Notifications

Held changes announced to the targets in rowbit-server.yml. A notification that + can't be delivered never affects the change; it stays held here.

`,1);function ea(e,t){Ve(t,!0);let n=M(null),r=M(``);async function i(){try{N(n,(await vi()).deliveries,!0),N(r,``)}catch(e){N(r,e.message,!0)}}si(()=>{i();let e=setInterval(i,1e4);return()=>clearInterval(e)});var a=$i(),o=R(I(a),4),s=e=>{var t=Yi(),n=L(t,!0);z(()=>Z(n,J(r))),X(e,t)};Q(o,e=>{J(r)&&e(s)});var c=R(o,2),l=e=>{X(e,Xi())};Q(c,e=>{J(n)&&J(n).length===0&&e(l)});var u=R(c,2),d=e=>{var t=Qi(),r=F(t),i=R(F(r));$(i,21,()=>J(n),e=>e.id,(e,t)=>{var n=Zi(),r=F(n),i=F(r),a=L(i);O(r);var o=R(r),s=L(o,!0),c=R(o),l=F(c),u=L(l,!0);O(c);var d=R(c),f=L(d,!0),p=R(d),m=L(p,!0),h=L(R(p),!0);O(n),z(e=>{$r(i,`href`,`#/changes/${J(t).change_id??``}`),Z(a,`#${J(t).change_id??``}`),Z(s,J(t).target),Vr(l,1,`state ${J(t).state===`delivered`?`applied`:J(t).state===`failed`?`failed`:`held`}`),Z(u,J(t).state),Z(f,J(t).attempts),Z(m,J(t).last_error??``),Z(h,e)},[()=>Si(J(t).created_at_ms)]),X(e,n)}),O(i),O(r),O(t),X(e,t)};Q(u,e=>{J(n)&&J(n).length&&e(d)}),X(e,a),He()}var ta=Y(`

The audit log's hash chain checks out.

`),na=Y(``),ra=Y(``),ia=Y(`

`),aa=Y(` `),oa=Y(` `),sa=Y(`

Log

#StateAgentWhatDecided byReasonWhen
`,1);function ca(e,t){Ve(t,!0);let n=[``,`proposed`,`held`,`approved`,`applied`,`rejected`,`failed`,`undone`],r=Gt({state:``,agent:``,table:``}),i=M(Gt([])),a=M(null),o=M(``);async function s(){try{N(i,(await fi(r)).changes,!0),N(o,``)}catch(e){N(o,e.message,!0)}}si(async()=>{s(),N(a,await _i().catch(e=>({ok:!1,problem:e.message})),!0)});var c=sa(),l=R(I(c),2),u=e=>{var t=vr(),n=I(t),r=e=>{X(e,ta())},i=e=>{var t=na(),n=L(t);z(()=>Z(n,`The audit log doesn't check out: ${J(a).problem??``}`)),X(e,t)};Q(n,e=>{J(a).ok?e(r):e(i,-1)}),X(e,t)};Q(l,e=>{J(a)&&e(u)});var d=R(l,2),f=F(d),p=R(F(f));$(p,20,()=>n,e=>e,(e,t)=>{var n=ra(),r=L(n,!0),i={};z(()=>{Z(r,t||`any`),i!==(i=t)&&(n.value=(n.__value=i)??``)}),X(e,n)}),O(p),Gr(p),O(f);var m=R(f,2),h=R(F(m));Qr(h),O(m);var g=R(m,2),_=R(F(g));Qr(_),O(g),Ce(2),O(d);var v=R(d,2),y=e=>{var t=ia(),n=L(t,!0);z(()=>Z(n,J(o))),X(e,t)};Q(v,e=>{J(o)&&e(y)});var b=R(v,2),x=F(b),S=R(F(x));$(S,21,()=>J(i),e=>e.id,(e,t)=>{var n=oa(),r=F(n),i=F(r),a=L(i,!0);O(r);var o=R(r);Ei(F(o),{get state(){return J(t).state}}),O(o);var s=R(o),c=L(s,!0),l=R(s),u=F(l),d=R(u),f=e=>{var n=_r();z(()=>Z(n,J(t).rows)),X(e,n)};Q(d,e=>{J(t).rows!=null&&e(f)});var p=R(d,2),m=e=>{var n=aa(),r=L(n,!0);z(()=>Z(r,J(t).table)),X(e,n)};Q(p,e=>{J(t).table&&e(m)}),O(l);var h=R(l),g=L(h,!0),_=R(h),v=L(_,!0),y=L(R(_),!0);O(n),z(e=>{$r(i,`href`,`#/changes/${J(t).id??``}`),Z(a,J(t).id),Z(c,J(t).agent),Z(u,`${wi[J(t).kind]??``??``} `),Z(g,J(t).decided_by??``),Z(v,J(t).reason??``),Z(y,e)},[()=>Si(J(t).created_at_ms)]),X(e,n)}),O(S),O(x),O(b),sr(`submit`,d,e=>{e.preventDefault(),s()}),Kr(p,()=>r.state,e=>r.state=e),ri(h,()=>r.agent,e=>r.agent=e),ri(_,()=>r.table,e=>r.table=e),X(e,c),He()}var la=Y(`

`),ua=Y(`

Nothing is waiting.

`),da=Y(` in`,1),fa=Y(` `),pa=Y(`
`),ma=Y(`
`),ha=Y(`

Waiting for a person

`,1);function ga(e,t){Ve(t,!0);let n=M(null),r=M(Gt([])),i=M(``);async function a(){try{let e=await fi({state:`held`});N(n,e.changes.reverse(),!0),N(r,e.interrupted,!0),N(i,``)}catch(e){N(i,e.message,!0)}}si(()=>{a();let e=setInterval(a,1e4);return()=>clearInterval(e)});var o=ha(),s=I(o),c=R(F(s)),l=e=>{var t=_r();z(()=>Z(t,`(${J(n).length??``})`)),X(e,t)};Q(c,e=>{J(n)&&e(l)}),O(s);var u=R(s,2),d=e=>{var t=la(),n=L(t);z(e=>Z(n,`Changes ${e??``} were interrupted while applying or undoing. Compare their rows + with the preview before anything else.`),[()=>J(r).join(`, `)]),X(e,t)};Q(u,e=>{J(r).length&&e(d)});var f=R(u,2),p=e=>{var t=la(),n=L(t,!0);z(()=>Z(n,J(i))),X(e,t)};Q(f,e=>{J(i)&&e(p)});var m=R(f,2),h=e=>{X(e,ua())};Q(m,e=>{J(n)&&J(n).length===0&&e(h)}),$(R(m,2),17,()=>J(n)??[],e=>e.id,(e,t)=>{var n=ma(),r=F(n),i=F(r),a=L(i),o=R(i,2),s=L(o,!0),c=L(R(o,2),!0);O(r);var l=R(r,2),u=F(l),d=R(u),f=e=>{var n=da(),r=L(I(n),!0);Ce(),z(e=>Z(r,e),[()=>xi(J(t).rows,`row`)]),X(e,n)};Q(d,e=>{J(t).rows!=null&&e(f)});var p=R(d,2),m=e=>{var n=fa(),r=L(n,!0);z(()=>Z(r,J(t).table)),X(e,n)};Q(p,e=>{J(t).table&&e(m)}),O(l);var h=R(l,2),g=e=>{var n=pa(),r=L(n);z(()=>Z(r,`Held: ${J(t).reason??``}`)),X(e,n)};Q(h,e=>{J(t).reason&&e(g)}),O(n),z(e=>{$r(n,`href`,`#/changes/${J(t).id??``}`),Z(a,`#${J(t).id??``}`),Z(s,J(t).agent),Z(c,e),Z(u,`${wi[J(t).kind]??`writes`??``} `)},[()=>Si(J(t).created_at_ms)]),X(e,n)}),X(e,o),He()}var _a=Y(`

`),va=Y(`
Database
Server
(MariaDB or MySQL)
Rowbit connects as
Connection
`),ya=Y(`

Unknown: the database URL isn't available to the review page.

`),ba=Y(`
Files
`,1),xa=Y(`
Notifications
`,1),Sa=Y(`

`),Ca=Y(` `),wa=Y(`
ConnectedSinceLast used
`),Ta=Y(`

Not connected over HTTP. Connect a client from your project: rowbit connect claude-code

`),Ea=Y(`

`,1),Da=Y(`

Database

Rowbit

Review page
Sign-in
Agents over HTTP
Held now
Version

Agents

`,1),Oa=Y(`

Setup

What this Rowbit guards, and where its agents are connected. Passwords are never shown. rowbit status shows the same for every setup on this machine.

`,1);function ka(e,t){Ve(t,!0);let n=M(null),r=M(``);async function i(){try{N(n,await yi(),!0),N(r,``)}catch(e){N(r,e.message,!0)}}si(()=>{i();let e=setInterval(i,15e3);return()=>clearInterval(e)});function a(e){let t={allow:`allows`,block:`refuses`}[e.default]??`holds`,n=e.rules===0?`${t} every write`:`${e.rules} rule${e.rules===1?``:`s`}, else ${t}`;return e.mode===`shadow`&&(n+=` (shadow mode: logged, not enforced)`),e.scoped&&(n+=`, scoped`),n}var o=Oa(),s=R(I(o),4),c=e=>{var t=_a(),n=L(t,!0);z(()=>Z(n,J(r))),X(e,t)};Q(s,e=>{J(r)&&e(c)});var l=R(s,2),u=e=>{var t=Da(),r=I(t),i=R(F(r),2),o=e=>{var t=va(),r=R(F(t)),i=L(F(r),!0);O(r);var a=R(r,3),o=L(F(a));Ce(),O(a);var s=R(a,3),c=L(F(s),!0);O(s);var l=L(R(s,3),!0);O(t),z(()=>{Z(i,J(n).database.database),Z(o,`${J(n).database.host??``}:${J(n).database.port??``}`),Z(c,J(n).database.user),Z(l,J(n).database.tls)}),X(e,t)},s=e=>{X(e,ya())};Q(i,e=>{J(n).database?e(o):e(s,-1)}),O(r);var c=R(r,2),l=R(F(c),2),u=F(l),d=e=>{var t=ba(),r=R(I(t)),i=L(F(r),!0);O(r),z(()=>Z(i,J(n).dir)),X(e,t)};Q(u,e=>{J(n).dir&&e(d)});var f=R(u,3),p=L(f,!0),m=R(f,3),h=L(m,!0),g=R(m,3),_=L(g,!0),v=R(g,2),y=e=>{var t=xa(),r=L(R(I(t)),!0);z(e=>Z(r,e),[()=>J(n).notify.join(`, `)]),X(e,t)};Q(v,e=>{J(n).notify?.length&&e(y)});var b=R(v,3),x=L(F(b),!0);O(b);var S=L(R(b,3),!0);O(l),O(c);var C=R(c,2);$(R(F(C),2),17,()=>J(n).agents,e=>e.id,(e,t)=>{var n=Ea(),r=I(n),i=L(F(r),!0);O(r);var o=R(r,2),s=e=>{var n=Sa(),r=L(n,!0);z(()=>Z(r,J(t).description)),X(e,n)};Q(o,e=>{J(t).description&&e(s)});var c=R(o,2),l=L(c,!0),u=R(c,2),d=e=>{var n=wa(),r=F(n),i=R(F(r));$(i,21,()=>J(t).connections,e=>e.label,(e,t)=>{var n=Ca(),r=F(n),i=L(r,!0),a=R(r),o=L(a,!0),s=L(R(a),!0);O(n),z((e,n)=>{Z(i,J(t).label),Z(o,e),Z(s,n)},[()=>Si(J(t).created_ms),()=>J(t).used_ms?Si(J(t).used_ms):`never`]),X(e,n)}),O(i),O(r),O(n),X(e,n)},f=e=>{X(e,Ta())};Q(u,e=>{J(t).connections.length?e(d):e(f,-1)}),z(e=>{Z(i,J(t).id),Z(l,e)},[()=>a(J(t))]),X(e,n)}),O(C),z(()=>{Z(p,J(n).review_page),Z(h,J(n).sign_in),Z(_,J(n).mcp??`off (agents start rowbit mcp themselves)`),Z(x,J(n).held),Z(S,J(n).version)}),X(e,t)};Q(l,e=>{J(n)&&e(u)}),X(e,o),He()}var Aa=Y(`

`),ja=Y(`

Nothing yet.

`),Ma=Y(` `),Na=Y(` `),Pa=Y(`

#StateAgentWhatWould haveWhen
`,1),Fa=Y(`

Shadow mode

Changes from agents in shadow mode that a rule would have held or blocked. They were applied, and + can be undone from their page.

`,1);function Ia(e,t){Ve(t,!0);let n=M(null),r=M(``);si(async()=>{try{let{changes:e}=await fi({shadowed:`true`}),t=new Map;for(let n of e){let e=n.shadowed.by;t.has(e)||t.set(e,[]),t.get(e).push(n)}N(n,[...t.entries()].sort((e,t)=>t[1].length-e[1].length),!0)}catch(e){N(r,e.message,!0)}});var i=Fa(),a=R(I(i),4),o=e=>{var t=Aa(),n=L(t,!0);z(()=>Z(n,J(r))),X(e,t)};Q(a,e=>{J(r)&&e(o)});var s=R(a,2),c=e=>{X(e,ja())};Q(s,e=>{J(n)&&J(n).length===0&&e(c)}),$(R(s,2),17,()=>J(n)??[],([e,t])=>e,(e,t)=>{var n=lt(()=>m(J(t),2));let r=()=>J(n)[0],i=()=>J(n)[1];var a=Pa(),o=I(a),s=F(o),c=L(R(s));O(o);var l=R(o,2),u=F(l),d=R(F(u));$(d,21,i,e=>e.id,(e,t)=>{var n=Na(),r=F(n),i=F(r),a=L(i,!0);O(r);var o=R(r);Ei(F(o),{get state(){return J(t).state}}),O(o);var s=R(o),c=L(s,!0),l=R(s),u=F(l),d=R(u),f=e=>{var n=_r();z(()=>Z(n,J(t).rows)),X(e,n)};Q(d,e=>{J(t).rows!=null&&e(f)});var p=R(d,2),m=e=>{var n=Ma(),r=L(n,!0);z(()=>Z(r,J(t).table)),X(e,n)};Q(p,e=>{J(t).table&&e(m)}),O(l);var h=R(l),g=L(h,!0),_=L(R(h),!0);O(n),z(e=>{$r(i,`href`,`#/changes/${J(t).id??``}`),Z(a,J(t).id),Z(c,J(t).agent),Z(u,`${wi[J(t).kind]??``??``} `),Z(g,J(t).shadowed.would??``),Z(_,e)},[()=>Si(J(t).created_at_ms)]),X(e,n)}),O(d),O(u),O(l),z(e=>{Z(s,`${r()??``} `),Z(c,`(${e??``})`)},[()=>xi(i().length,`change`)]),X(e,a)}),X(e,i),He()}var La=Y(`Guarding on as `),Ra=Y(` `,1),za=Y(`

Loading...

`),Ba=Y(`
Rowbit
`,1);function Va(e,t){Ve(t,!0);let n=M(``),r=M(null),i=M(Gt(location.hash));si(()=>{di().then(e=>{N(n,e.reviewer,!0),yi().then(e=>N(r,e.database,!0)).catch(()=>{})});let e=()=>N(i,location.hash,!0);return addEventListener(`hashchange`,e),()=>removeEventListener(`hashchange`,e)});let a=lt(()=>J(i).match(/^#\/changes\/(\d+)$/)?.[1]);async function o(){await bi().catch(()=>{}),location.assign(`/login`)}var s=Ba(),c=I(s),l=R(F(c),2),u=F(l);let d;var f=R(u,2);let p;var m=R(f,2);let h;var g=R(m,2);let _;var v=R(g,2);let y;O(l);var b=R(l,2),x=e=>{var t=La(),n=R(F(t)),i=L(n,!0),a=R(n,2),o=L(a),s=L(R(a,2),!0);O(t),z(()=>{Z(i,J(r).database),Z(o,`${J(r).host??``}:${J(r).port??``}`),Z(s,J(r).user)}),X(e,t)};Q(b,e=>{J(r)&&e(x)});var S=R(b,2),C=F(S),ee=e=>{var t=Ra(),r=I(t),i=R(r);z(()=>Z(r,`${J(n)??``} `)),cr(`click`,i,o),X(e,t)};Q(C,e=>{J(n)&&e(ee)}),O(S),O(c);var te=R(c,2),ne=F(te),re=e=>{X(e,za())},ie=e=>{var t=vr();kr(I(t),()=>J(a),e=>{Ji(e,{get id(){return J(a)}})}),X(e,t)},ae=e=>{ca(e,{})},oe=e=>{Ia(e,{})},se=e=>{ea(e,{})},ce=e=>{ka(e,{})},le=e=>{ga(e,{})};Q(ne,e=>{J(n)?J(a)?e(ie,1):J(i)===`#/log`?e(ae,2):J(i)===`#/shadow`?e(oe,3):J(i)===`#/notifications`?e(se,4):J(i)===`#/setup`?e(ce,5):e(le,-1):e(re)}),O(te),z(()=>{d=Vr(u,1,``,null,d,{active:J(i)===``||J(i)===`#/`}),p=Vr(f,1,``,null,p,{active:J(i)===`#/log`}),h=Vr(m,1,``,null,h,{active:J(i)===`#/shadow`}),_=Vr(g,1,``,null,_,{active:J(i)===`#/notifications`}),y=Vr(v,1,``,null,y,{active:J(i)===`#/setup`})}),X(e,s),He()}lr([`click`]),Cr(Va,{target:document.getElementById(`app`)}); \ No newline at end of file diff --git a/crates/rowbit-web/src/api.rs b/crates/rowbit-web/src/api.rs index 7f12be6..0eba154 100644 --- a/crates/rowbit-web/src/api.rs +++ b/crates/rowbit-web/src/api.rs @@ -466,6 +466,28 @@ pub(crate) async fn verify(State(app): State, headers: HeaderMap) -> Respon } } +/// What this Rowbit guards and who is connected: the Setup view. +pub(crate) async fn setup(State(app): State, headers: HeaderMap) -> Response { + if let Err(r) = signed_in(&app, &headers) { + return r.into_response(); + } + let agents = match app.engine.agents_overview() { + Ok(a) => a, + Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }; + let held = match app.engine.held() { + Ok(h) => h.len(), + Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }; + let mut out = (*app.setup).clone(); + if !out.is_object() { + out = json!({}); + } + out["agents"] = json!(agents); + out["held"] = json!(held); + Json(out).into_response() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rowbit-web/src/lib.rs b/crates/rowbit-web/src/lib.rs index b33e18d..7712edb 100644 --- a/crates/rowbit-web/src/lib.rs +++ b/crates/rowbit-web/src/lib.rs @@ -69,6 +69,9 @@ pub struct Web { pub secure: bool, /// Serve agents over MCP at `/mcp`, with this default database. pub mcp: Option, + /// What the Setup view shows besides the agents: the database without + /// its password, Rowbit's directory, sign-in. Built by `rowbit serve`. + pub setup: serde_json::Value, } #[derive(Clone)] @@ -78,6 +81,7 @@ struct App { sign_in: Arc, secure: bool, sessions: Arc>>, + setup: Arc, /// Wrong passwords in a row, across all clients. Held for the whole of /// a sign-in, so passwords are checked one at a time and each wrong one /// waits longer: the wait caps the guessing rate, and only one Argon2 @@ -109,6 +113,7 @@ pub fn router(engine: Arc, web: Web) -> Router { sign_in: Arc::new(web.sign_in), secure: web.secure, sessions: Arc::new(Mutex::new(HashMap::new())), + setup: Arc::new(web.setup), failures: Arc::new(tokio::sync::Mutex::new(0)), }; Router::new() @@ -127,6 +132,7 @@ pub fn router(engine: Arc, web: Web) -> Router { .route("/api/changes/{id}/reject", post(api::reject)) .route("/api/changes/{id}/undo", post(api::undo)) .route("/api/verify", get(api::verify)) + .route("/api/setup", get(api::setup)) .route("/api/deliveries", get(api::deliveries)) .merge(match mcp { // Every request signs in with its agent's token first. diff --git a/crates/rowbit-web/tests/mcp_http.rs b/crates/rowbit-web/tests/mcp_http.rs index d23bf0e..8f1deeb 100644 --- a/crates/rowbit-web/tests/mcp_http.rs +++ b/crates/rowbit-web/tests/mcp_http.rs @@ -45,6 +45,7 @@ async fn server(url: &str) -> Server { let router = rowbit_web::router( engine, rowbit_web::Web { + setup: serde_json::Value::Null, sign_in: rowbit_web::SignIn::Password { hash: rowbit_web::hash_password(&rowbit_web::new_password()), }, diff --git a/crates/rowbit-web/tests/oidc.rs b/crates/rowbit-web/tests/oidc.rs index c4917c4..49e990a 100644 --- a/crates/rowbit-web/tests/oidc.rs +++ b/crates/rowbit-web/tests/oidc.rs @@ -222,6 +222,7 @@ async fn rowbit(p: &Provider) -> axum::Router { rowbit_web::router( engine, rowbit_web::Web { + setup: serde_json::Value::Null, sign_in: rowbit_web::SignIn::Oidc(Box::new(login)), hosts: vec![HOST.into()], secure: false, diff --git a/crates/rowbit-web/tests/page.rs b/crates/rowbit-web/tests/page.rs index 511e2ac..a024821 100644 --- a/crates/rowbit-web/tests/page.rs +++ b/crates/rowbit-web/tests/page.rs @@ -49,6 +49,7 @@ impl Page { router: rowbit_web::router( engine.clone(), rowbit_web::Web { + setup: serde_json::Value::Null, sign_in: rowbit_web::SignIn::Password { hash }, hosts: vec![HOST.to_owned()], secure: false, @@ -349,6 +350,8 @@ async fn other_hosts_are_refused_and_the_ui_needs_a_session() { assert_eq!(status, StatusCode::SEE_OTHER, "{html}"); let (status, _) = page.api("/api/changes").await; assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _) = page.api("/api/setup").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); // Found by a browser test: with `Referrer-Policy: no-referrer`, browsers // send `Origin: null` on the page's own forms, so the origin check @@ -383,6 +386,14 @@ async fn other_hosts_are_refused_and_the_ui_needs_a_session() { ); let (_, me) = page.api("/api/session").await; assert_eq!(me["reviewer"], "philip"); + // The Setup view: every agent in rowbit.yml, and nothing held yet. + let (status, setup) = page.api("/api/setup").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(setup["held"], 0); + assert!( + setup["agents"].as_array().is_some_and(|a| !a.is_empty()), + "{setup}" + ); let (status, js) = page.get_path(HOST, "/assets/app.js").await; assert_eq!(status, StatusCode::OK); assert!(!js.is_empty()); diff --git a/docs/connect.md b/docs/connect.md index b226979..00779f4 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -16,7 +16,7 @@ cd ~/projects/my-app rowbit connect claude-code ``` -Rowbit finds its setup on its own: the folder's `rowbit.yml` if it has one, else `~/.rowbit/default`, else your only setup in `~/.rowbit` or the demo, and says which it used. With several and no `default`, it lists them and asks for `--dir`. For Claude Code the entry applies to this project only, so other projects don't see Rowbit; `--scope user` adds it everywhere. Each project you connect gets a token of its own, so connecting a second project doesn't sign out the first, and `rowbit agents` lists them all. +Rowbit finds its setup on its own: the folder's `rowbit.yml` if it has one, else `~/.rowbit/default`, else your only setup in `~/.rowbit` or the demo, and says which it used. With several and no `default`, it lists them and asks for `--dir`. For Claude Code the entry applies to this project only, so other projects don't see Rowbit; `--scope user` adds it everywhere. Each project you connect gets a token of its own, so connecting a second project doesn't sign out the first. `rowbit status` lists every setup, the database each guards and where each agent is connected; the review page's Setup view shows the same for its own. Rowbit doesn't write `.mcp.json` (Claude Code's project scope), since that file is meant to be committed and would carry the token. diff --git a/docs/quickstart.md b/docs/quickstart.md index 422c4c0..59bbf49 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -57,7 +57,9 @@ Then ask for a change. Whatever it is, Rowbit previews it on a copy and holds it ## 4. Review it -Open the review page and sign in with the password setup printed and your name, which the audit log records. It shows the held change with each row before and after. Approve it, and Rowbit writes exactly those rows, or nothing if any changed since the preview. Undo takes one change back. +Open the review page and sign in with the password setup printed and your name, which the audit log records. It shows the held change with each row before and after. Approve it, and Rowbit writes exactly those rows, or nothing if any changed since the preview. Undo takes one change back. Its header says which database it guards, and its Setup view shows the database, the agents and where each is connected; `rowbit status` shows the same for every setup on this machine. + +To use Rowbit in another project, `cd` into it and run `rowbit connect claude-code` (or another client); see [Connect your agent](connect.md#add-rowbit-to-a-project-you-already-have). The same from a terminal: diff --git a/docs/spec/setup.md b/docs/spec/setup.md index ff543b4..59c663e 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -190,6 +190,25 @@ The home page and the README also offer install links for VS Code (`https://insi So that a held write ends with the person knowing where to look, `propose_write` and `change_status` return `review_url` for a held change: `/changes/`, or `http:///changes/` without a `public_url`, the same link notifications use. `rowbit serve` records that address in the state file when it starts, so `rowbit mcp` over stdio, which shares the file, links to the same place; before `rowbit serve` has ever run, the link uses `http://127.0.0.1:8080`. The server's instructions tell the agent to give the person that link, as a Markdown link so clients that render Markdown make it clickable, and not to retry the write in another form. The link reveals nothing: the page needs sign-in, and the agent could reach its address already. Opened signed out, it goes through sign-in (carrying only the change's number) and then to that change. See [Agent tools](change-lifecycle.md#agent-tools-mcp). +## What Rowbit is connected to + +`rowbit status` lists every setup on this machine: those in `~/.rowbit` and the demo, or with `--dir` (or in a folder of Rowbit's) that one. For each it prints its folder; the database it guards, as database, host, port, user and TLS, never the password; the review page's address and whether something answers there, with the number of held changes; and each agent in `rowbit.yml` with its rules in brief ("holds every write", "3 rules, else holds", shadow mode, scoped) and where it is connected. It reads files only and never connects to the database, so it works when the database is down. + +``` +shop ~/.rowbit/shop + database: shop on db.internal:3306 as rowbit, TLS, certificate and host name checked + review page: http://127.0.0.1:8080/, running, 2 held + agent claude-code: holds every write + Claude Code in ~/projects/my-app (last used 2026-11-07T14:02:11Z) + Cursor (never used) +demo ~/rowbit-demo + ... +``` + +Where an agent is connected comes from its tokens over HTTP: each entry `rowbit connect` added, by its label, and the token from `rowbit agents token`. An entry for `rowbit mcp` over stdio holds no token, so it isn't listed. A label says where an entry was added; removing the entry by hand in the client leaves the token, and so the line, until `rowbit agents revoke`. + +The review page shows the same for its own setup: a line in the header ("Guarding shop on db.internal:3306 as rowbit", linking to the Setup view), and a Setup view with the database, Rowbit's folder, review page and MCP addresses, sign-in, notification targets, the number held, the version, and each agent with its rules and connections, each with when it was made and last used. The page reads it from `/api/setup`, behind the same sign-in as everything else. One review page shows one setup; a page across several databases comes with named connections. + ## Links in the terminal A plain URL in a terminal is only clickable if the terminal spots it, and some open it only on ⌘-click or Ctrl-click. So where Rowbit prints an address for a person (the review page in `setup`, `demo` and `serve`, the docs link in `setup`), it marks it as a link with OSC 8, in terminals known to show those: Ghostty, iTerm2, WezTerm, kitty, VS Code's terminal, Windows Terminal, Hyper, Rio, Warp and VTE-based ones (GNOME Terminal and others). It prints the address as it is when the output isn't a terminal, in Terminal.app, which doesn't support them, and inside tmux or screen, which drop them unless set up to pass them on. `ROWBIT_HYPERLINKS=1` or `0` overrides the choice. The link's text is the address itself, so copying still works. diff --git a/web/src/App.svelte b/web/src/App.svelte index 0dd7024..a52137f 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -5,13 +5,18 @@ import Deliveries from './Deliveries.svelte'; import Log from './Log.svelte'; import Queue from './Queue.svelte'; + import Setup from './Setup.svelte'; import Shadow from './Shadow.svelte'; let reviewer = $state(''); + let guarding = $state(null); let route = $state(location.hash); onMount(() => { - api.me().then((s) => (reviewer = s.reviewer)); + api.me().then((s) => { + reviewer = s.reviewer; + api.setup().then((s) => (guarding = s.database)).catch(() => {}); + }); const onHash = () => (route = location.hash); addEventListener('hashchange', onHash); return () => removeEventListener('hashchange', onHash); @@ -32,7 +37,13 @@ Log Shadow Notifications + Setup + {#if guarding} + + Guarding {guarding.database} on {guarding.host}:{guarding.port} as {guarding.user} + + {/if} {#if reviewer}{reviewer} {/if} @@ -49,6 +60,8 @@ {:else if route === '#/notifications'} + {:else if route === '#/setup'} + {:else} {/if} diff --git a/web/src/Setup.svelte b/web/src/Setup.svelte new file mode 100644 index 0000000..ea5ec39 --- /dev/null +++ b/web/src/Setup.svelte @@ -0,0 +1,93 @@ + + +

Setup

+

+ What this Rowbit guards, and where its agents are connected. Passwords are never shown. + rowbit status shows the same for every setup on this machine. +

+{#if error}

{error}

{/if} +{#if setup} +
+

Database

+ {#if setup.database} +
+
Database
{setup.database.database}
+
Server
{setup.database.host}:{setup.database.port} (MariaDB or MySQL)
+
Rowbit connects as
{setup.database.user}
+
Connection
{setup.database.tls}
+
+ {:else} +

Unknown: the database URL isn't available to the review page.

+ {/if} +
+ +
+

Rowbit

+
+ {#if setup.dir}
Files
{setup.dir}
{/if} +
Review page
{setup.review_page}
+
Sign-in
{setup.sign_in}
+
Agents over HTTP
{setup.mcp ?? 'off (agents start rowbit mcp themselves)'}
+ {#if setup.notify?.length}
Notifications
{setup.notify.join(', ')}
{/if} +
Held now
{setup.held}
+
Version
{setup.version}
+
+
+ +
+

Agents

+ {#each setup.agents as a (a.id)} +

{a.id}

+ {#if a.description}

{a.description}

{/if} +

{rules(a)}

+ {#if a.connections.length} +
+ + + + {#each a.connections as c (c.label)} + + + + + + {/each} + +
ConnectedSinceLast used
{c.label}{ago(c.created_ms)}{c.used_ms ? ago(c.used_ms) : 'never'}
+
+ {:else} +

Not connected over HTTP. Connect a client from your project: rowbit connect claude-code

+ {/if} + {/each} +
+{/if} diff --git a/web/src/api.js b/web/src/api.js index 1d266cf..3e7d70d 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -47,4 +47,5 @@ export const reject = (id, reason) => call('POST', `/api/changes/${id}/reject`, export const undo = (id) => call('POST', `/api/changes/${id}/undo`, {}); export const verify = () => call('GET', '/api/verify'); export const deliveries = () => call('GET', '/api/deliveries'); +export const setup = () => call('GET', '/api/setup'); export const logout = () => call('POST', '/api/logout', {}); diff --git a/web/src/app.css b/web/src/app.css index b5f7501..e7f3cb9 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -74,3 +74,11 @@ button.link { border: 0; background: none; color: var(--accent); padding: 0; } .pager { display: flex; gap: 12px; align-items: center; } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .shadow { color: var(--held); } + +header .guarding { color: var(--muted); text-decoration: none; font-size: 0.9rem; } +header .guarding:hover { color: var(--ink); } +header .guarding code { color: var(--ink); } +.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 0; } +.facts dt { color: var(--muted); } +.facts dd { margin: 0; overflow-wrap: anywhere; } +h3 { font-size: 1rem; margin: 16px 0 4px; } From 7ba0df640500615c4c87cb6a685685b8ec795da2 Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 15:59:07 +0200 Subject: [PATCH 4/9] Files named on the command line or in the environment need no setup Signed-off-by: Ermag --- crates/rowbit-cli/src/place.rs | 14 +++++++++++++- docs/spec/setup.md | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/rowbit-cli/src/place.rs b/crates/rowbit-cli/src/place.rs index 7433aca..482a72c 100644 --- a/crates/rowbit-cli/src/place.rs +++ b/crates/rowbit-cli/src/place.rs @@ -193,8 +193,20 @@ fn pick(found: &[Found]) -> Picked<'_> { /// Whether the command reads a setup's files, so finding none is an error /// worth stopping for. `ping`, `policy`, `server` and `connect` with no -/// client name (it lists them) work without one. +/// client name (it lists them) work without one, and so does a command +/// told where its files are (`--policy`, `ROWBIT_STORE`, ...). fn needs_files(args: &[OsString]) -> bool { + let named = args.iter().any(|a| { + let a = a.to_string_lossy(); + ["--policy", "--store", "--config"] + .iter() + .any(|f| a == *f || a.starts_with(&format!("{f}="))) + }) || ["ROWBIT_POLICY", "ROWBIT_STORE", "ROWBIT_SERVER_CONFIG"] + .iter() + .any(|v| std::env::var_os(v).is_some()); + if named { + return false; + } let (command, _) = command(args); match command.as_deref() { None | Some("ping" | "policy" | "server" | "help") => false, diff --git a/docs/spec/setup.md b/docs/spec/setup.md index b094aa3..74ee9c1 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -82,7 +82,7 @@ It never runs a statement that changes the database or its users. Where somethin ## Where commands find their files -Every command but `demo` and `setup` takes `--dir ` (or `ROWBIT_DIR`), after the command's name, and then finds `rowbit.yml`, `rowbit-server.yml`, `rowbit.db` and the `env` file in that directory. Without it, a command uses the current directory if it holds a `rowbit.yml`, `rowbit-server.yml` or `rowbit.db`, and otherwise `~/.rowbit/default` if that holds a setup. Without that either, it looks at every setup in `~/.rowbit` and the demo (the one `~/.rowbit/demo` links to, which `rowbit demo` makes and `rowbit demo clean` removes, or else `~/rowbit-demo`): if there is one, it uses it and says which on standard error; if there are several, it lists them and asks for `--dir`; if there are none, it says to run `rowbit setup`. `ping`, `policy`, `server` and `rowbit connect` without a client work without a setup. So `rowbit connect claude-code` works from any project folder. `rowbit setup` refuses a name that is a link, so it never writes into the demo's directory. A flag or variable naming one file (`--policy`, `--store`, `--config` and their variables) still wins for that file. `--dir` before `demo` or `setup` is refused, since they have options of their own. +Every command but `demo` and `setup` takes `--dir ` (or `ROWBIT_DIR`), after the command's name, and then finds `rowbit.yml`, `rowbit-server.yml`, `rowbit.db` and the `env` file in that directory. Without it, a command uses the current directory if it holds a `rowbit.yml`, `rowbit-server.yml` or `rowbit.db`, and otherwise `~/.rowbit/default` if that holds a setup. Without that either, it looks at every setup in `~/.rowbit` and the demo (the one `~/.rowbit/demo` links to, which `rowbit demo` makes and `rowbit demo clean` removes, or else `~/rowbit-demo`): if there is one, it uses it and says which on standard error; if there are several, it lists them and asks for `--dir`; if there are none, it says to run `rowbit setup`. `ping`, `policy`, `server`, `rowbit connect` without a client, and a command given its files (`--policy`, `--store`, `--config` or their variables) work without one. So `rowbit connect claude-code` works from any project folder. `rowbit setup` refuses a name that is a link, so it never writes into the demo's directory. A flag or variable naming one file (`--policy`, `--store`, `--config` and their variables) still wins for that file. `--dir` before `demo` or `setup` is refused, since they have options of their own. The database URL is `--url` if given. Otherwise, in a setup's directory, it's the `env` file's, even when `ROWBIT_DATABASE_URL` is set: that may be left in the shell from the demo, and pairing a setup's state with another database would go unnoticed. Rowbit says so on standard error when the two differ. Without an `env` file, `ROWBIT_DATABASE_URL` is used, as in v0.2.1. Rowbit refuses an `env` file that others can read, since it holds the database's password. From 80f209a9c715648ad635cc8644102892ee2f540c Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 23:03:11 +0200 Subject: [PATCH 5/9] Review fixes: sign out one connection, keep the old entry until the new is written - rowbit agents lists each token with an id; rowbit agents revoke --connection signs out one, --single only the token from before 0.2.3. - A reconnect's old token is revoked only once the client entry holding the new one is written, so a failed connect leaves the old entry working. - Printed tokens are labelled with their time, so they don't replace one another. - --new-token revokes the single token from before 0.2.3; without it, connect says the agent still has one. - Marking a token used can't bring back a revoked connection's row. - needs_files finds connect's arguments wherever it sits; "here" compares canonical paths; a setup named demo isn't called the demo. Signed-off-by: Ermag --- crates/rowbit-cli/src/connect.rs | 62 ++++++++++++++---- crates/rowbit-cli/src/main.rs | 47 ++++++++++++-- crates/rowbit-cli/src/place.rs | 27 ++++++-- crates/rowbit-cli/tests/connect.rs | 18 +++++ crates/rowbit-cli/tests/serve.rs | 8 ++- crates/rowbit-store/src/lib.rs | 101 ++++++++++++++++++++++++----- crates/rowbit-store/src/tests.rs | 40 ++++++++++-- docs/connect.md | 3 +- docs/spec/server.md | 2 +- docs/spec/setup.md | 2 +- 10 files changed, 255 insertions(+), 55 deletions(-) diff --git a/crates/rowbit-cli/src/connect.rs b/crates/rowbit-cli/src/connect.rs index d414c6d..13166f8 100644 --- a/crates/rowbit-cli/src/connect.rs +++ b/crates/rowbit-cli/src/connect.rs @@ -765,9 +765,9 @@ pub struct ConnectArgs { /// Have the client start `rowbit mcp` itself, even with mcp.http on #[arg(long)] stdio: bool, - /// No longer needed: each client entry gets a token of its own, and - /// connecting the same place again replaces only that one - #[arg(long, hide = true)] + /// Also revoke the agent's single token from before 0.2.3 (each client + /// entry now gets a token of its own) + #[arg(long)] new_token: bool, /// Replace an entry of the same name in the client #[arg(long)] @@ -927,14 +927,17 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { } let mut label = connection_label(client, a.scope, &env.cwd); if a.print { - // Added by hand, maybe somewhere else: don't sign out an entry - // `rowbit connect` added here. - label.push_str(" (printed)"); + // Added by hand, maybe somewhere else: each printed token is a + // connection of its own, which replaces none. + label.push_str(&format!(" (printed {})", printed_at())); } + let mut issued = None; let server = if http { + let (token, id) = issue_token(&a, &agent, &label)?; + issued = Some(id); Server::Http { url: format!("{}/mcp", address.base()), - token: issue_token(&a, &agent, &label)?, + token, } } else { stdio_server(&a, &agent)? @@ -946,17 +949,36 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { let applied = match apply(client, &env, &a.name, &server, a.scope, a.force) { Ok(applied) => applied, Err(e) => { - if http { - // Nothing holds the new token; don't leave it valid. + if let Some(id) = &issued { + // Nothing holds the new token; don't leave it valid. An + // entry from before keeps its own. let _ = a .store .open() - .and_then(|mut s| Ok(s.revoke_connection_token(&agent, &label)?)); - eprintln!("rowbit: the new token for {label} is revoked"); + .and_then(|mut s| Ok(s.revoke_connection(&agent, id)?)); + eprintln!( + "rowbit: the new token is revoked; an entry that was there before still works" + ); } return Err(e); } }; + if let Some(id) = &issued { + let mut store = a.store.open()?; + // The entry holding the new token is written: the one it replaced + // goes. + store.retire_older_connections(&agent, &label, id)?; + if store.agent_token_times(&agent)?.is_some() { + if a.new_token { + store.revoke_single_token(&agent)?; + println!("{agent}'s single token from before is revoked."); + } else { + println!( + "{agent} also has a single token from before 0.2.3, which still works. If no client uses it any more: rowbit agents revoke {agent} --single" + ); + } + } + } if applied.tightened { println!( "{} was readable by other users; it is now yours only, since it holds the agent's credentials.", @@ -1001,7 +1023,10 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { // `rowbit demo` says where to open the client itself. _ if a.dir.is_some() => {} Client::ClaudeCode if a.scope == Scope::Local => { - let here = std::env::current_dir().ok().as_deref() == Some(env.cwd.as_path()); + let canonical = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_owned()); + let here = std::env::current_dir() + .ok() + .is_some_and(|d| canonical(&d) == canonical(&env.cwd)); if here { println!( "It applies in this folder only. Start Claude Code here, or restart it if it's open, and ask it to use Rowbit." @@ -1124,12 +1149,21 @@ fn pick_agent(a: &ConnectArgs) -> anyhow::Result { /// A token for this client entry: one per client and place, so connecting /// the agent in another project leaves the others signed in, and /// connecting the same place again replaces only its own. -fn issue_token(a: &ConnectArgs, agent: &str, label: &str) -> anyhow::Result { +fn issue_token(a: &ConnectArgs, agent: &str, label: &str) -> anyhow::Result<(String, String)> { let mut store = rowbit_store::Store::open(&a.store.store) .with_context(|| format!("opening {}", a.store.store.display()))?; Ok(store.issue_connection_token(agent, label)?) } +/// When a token was printed, to tell printed tokens apart. +fn printed_at() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + rowbit_engine::rfc3339(secs) +} + /// Which client, and where it uses Rowbit: "Claude Code in ~/app". pub fn connection_label(client: Client, scope: Scope, cwd: &Path) -> String { match (client, scope) { @@ -1186,7 +1220,7 @@ fn print_address(a: &ConnectArgs) -> anyhow::Result<()> { "turn on mcp.http in rowbit-server.yml for an address to give a client, or name a client for its stdio setup" ); let agent = pick_agent(a)?; - let token = issue_token(a, &agent, &format!("{} (printed)", a.name))?; + let (token, _) = issue_token(a, &agent, &format!("{} (printed {})", a.name, printed_at()))?; println!("URL: {}/mcp", address.base()); println!("Header: Authorization: Bearer {token}"); Ok(()) diff --git a/crates/rowbit-cli/src/main.rs b/crates/rowbit-cli/src/main.rs index d17d77e..4978764 100644 --- a/crates/rowbit-cli/src/main.rs +++ b/crates/rowbit-cli/src/main.rs @@ -506,8 +506,19 @@ fn mysql_database(url: &str) -> anyhow::Result { enum AgentsCommand { /// Make a new token for an agent, replacing any it had, and print it once Token { agent: String }, - /// Remove an agent's token - Revoke { agent: String }, + /// Sign an agent out: every token it has, or one + /// + /// `rowbit agents` lists each token with its id. + Revoke { + agent: String, + /// Only this connection's token, by the id `rowbit agents` shows + #[arg(long, conflicts_with = "single")] + connection: Option, + /// Only the single token from `rowbit agents token` (or + /// `rowbit connect` before 0.2.3) + #[arg(long)] + single: bool, + }, } #[derive(Subcommand)] @@ -801,10 +812,27 @@ async fn main() -> anyhow::Result<()> { ); println!("{token}"); } - Some(AgentsCommand::Revoke { agent }) => { + Some(AgentsCommand::Revoke { + agent, + connection, + single, + }) => { let mut store = store.open()?; - if store.revoke_agent_token(&agent)? { - println!("{agent}'s token is revoked"); + if let Some(id) = connection { + match store.revoke_connection(&agent, &id)? { + Some(label) => println!("{agent}'s token for {label} is revoked"), + None => anyhow::bail!( + "{agent} has no connection {id}; `rowbit agents` lists them with their ids" + ), + } + } else if single { + if store.revoke_single_token(&agent)? { + println!("{agent}'s single token is revoked"); + } else { + println!("{agent} had no single token"); + } + } else if store.revoke_agent_token(&agent)? { + println!("every token of {agent}'s is revoked"); } else { println!("{agent} had no token"); } @@ -827,20 +855,25 @@ async fn main() -> anyhow::Result<()> { println!("{agent}"); if let Some((created, last)) = manual { println!( - " the agent's own token (rowbit agents token, or rowbit connect before 0.2.3): made {}, {}", + " {:<10} single token (rowbit agents token, or rowbit connect before 0.2.3): made {}, {}", + "--single", review::when(created), used(last) ); } for t in mine { println!( - " {}: made {}, {}", + " {:<10} {}: made {}, {}", + t.id, t.label, review::when(t.created_ms), used(t.used_ms) ); } } + println!( + "\nSign one out: rowbit agents revoke --connection (or --single); all of an agent's: rowbit agents revoke " + ); } } } diff --git a/crates/rowbit-cli/src/place.rs b/crates/rowbit-cli/src/place.rs index 482a72c..bf30d52 100644 --- a/crates/rowbit-cli/src/place.rs +++ b/crates/rowbit-cli/src/place.rs @@ -115,7 +115,7 @@ pub struct Found { impl Found { pub fn describe(&self) -> String { let shown = tilde(&self.dir); - if self.name == "demo" { + if self.name == "demo" && crate::demo::is_demo(&self.dir) { format!("the demo in {shown}") } else { format!("{} in {shown}", self.name) @@ -210,10 +210,27 @@ fn needs_files(args: &[OsString]) -> bool { let (command, _) = command(args); match command.as_deref() { None | Some("ping" | "policy" | "server" | "help") => false, - Some("connect") => args - .iter() - .skip(2) - .any(|a| a == "--print" || !a.to_string_lossy().starts_with('-')), + Some("connect") => { + // What follows the command's name: a client, or --print. + let after = args + .iter() + .skip(1) + .skip_while(|a| a.to_string_lossy() != "connect") + .skip(1); + let mut after = after.peekable(); + let mut needs = false; + while let Some(a) = after.next() { + let a = a.to_string_lossy(); + if a == "--print" { + needs = true; + } else if a == "--agent" || a == "--name" || a == "--scope" || a == "--url" { + after.next(); + } else if !a.starts_with('-') { + needs = true; + } + } + needs + } Some(_) => !args.iter().any(|a| a == "--help" || a == "-h"), } } diff --git a/crates/rowbit-cli/tests/connect.rs b/crates/rowbit-cli/tests/connect.rs index e77595a..feab8a5 100644 --- a/crates/rowbit-cli/tests/connect.rs +++ b/crates/rowbit-cli/tests/connect.rs @@ -321,6 +321,24 @@ fn a_project_folder_finds_the_setup_in_rowbit_home() { ); let (_, agents) = in_app(&["agents"]); assert!(agents.contains("Claude Code in "), "{agents}"); + // Reconnecting the same place leaves one token for it. + let (ok, out) = in_app(&["connect", "claude-code", "--force"]); + assert!(ok, "{out}"); + let (_, agents) = in_app(&["agents"]); + assert_eq!(agents.matches(": made").count(), 1, "{agents}"); + // And that one can be signed out on its own, by its id. + let id = agents + .lines() + .find(|l| l.contains("Claude Code in ")) + .and_then(|l| l.split_whitespace().next()) + .unwrap() + .to_owned(); + let (ok, out) = in_app(&["agents", "revoke", "coding-agent", "--connection", &id]); + assert!(ok && out.contains("Claude Code in "), "{out}"); + let (_, agents) = in_app(&["agents"]); + assert!(agents.contains("coding-agent no token"), "{agents}"); + let (ok, out) = in_app(&["connect", "claude-code", "--force"]); + assert!(ok, "{out}"); // Two, and neither the default: Rowbit asks which. std::fs::create_dir_all(c.home.join(".rowbit/other")).unwrap(); diff --git a/crates/rowbit-cli/tests/serve.rs b/crates/rowbit-cli/tests/serve.rs index a0f90eb..5460859 100644 --- a/crates/rowbit-cli/tests/serve.rs +++ b/crates/rowbit-cli/tests/serve.rs @@ -75,12 +75,16 @@ fn agent_tokens_are_shown_once_and_revocable() { assert_eq!(token.len(), 47); let (ok, out) = rowbit(&["agents"], &d); assert!( - ok && out.contains("refund-bot\n the agent's own token") && out.contains("never used"), + ok && out.contains("refund-bot\n --single single token") && out.contains("never used"), "{out}" ); assert!(!out.contains(&token)); + let (ok, out) = rowbit(&["agents", "revoke", "refund-bot", "--connection", "abcdef12"], &d); + assert!(!ok && out.contains("no connection"), "{out}"); + let (ok, out) = rowbit(&["agents", "revoke", "refund-bot", "--single"], &d); + assert!(ok && out.contains("single token is revoked"), "{out}"); let (ok, out) = rowbit(&["agents", "revoke", "refund-bot"], &d); - assert!(ok && out.contains("revoked"), "{out}"); + assert!(ok && out.contains("had no token"), "{out}"); let (_, out) = rowbit(&["agents"], &d); assert!(out.contains("refund-bot no token"), "{out}"); } diff --git a/crates/rowbit-store/src/lib.rs b/crates/rowbit-store/src/lib.rs index 8f0846f..f8b06c5 100644 --- a/crates/rowbit-store/src/lib.rs +++ b/crates/rowbit-store/src/lib.rs @@ -144,6 +144,10 @@ pub enum DeliveryOutcome { /// one per place it is connected, each labelled with where it is. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionToken { + /// The first characters of the token's hash, which name it in + /// `rowbit agents revoke --connection`. Not stored; filled in on reading. + #[serde(skip)] + pub id: String, pub agent: String, /// Which client, and where: "Claude Code in ~/projects/app". pub label: String, @@ -152,6 +156,8 @@ pub struct ConnectionToken { } const CONNECTION: &str = "connection."; +/// How many characters of a connection token's hash name it. +pub const CONNECTION_ID_LEN: usize = 8; /// Every connection token, by its hash. fn connection_tokens(conn: &Connection) -> Result, StoreError> { @@ -164,9 +170,11 @@ fn connection_tokens(conn: &Connection) -> Result let mut out = Vec::new(); for row in rows { let (key, value) = row?; - let t: ConnectionToken = + let mut t: ConnectionToken = serde_json::from_str(&value).map_err(|e| StoreError::Corrupt(format!("{key}: {e}")))?; - out.push((key[CONNECTION.len()..].to_owned(), t)); + let hash = key[CONNECTION.len()..].to_owned(); + t.id = hash.chars().take(CONNECTION_ID_LEN).collect(); + out.push((hash, t)); } Ok(out) } @@ -706,14 +714,16 @@ impl Store { Ok(had || had_connections) } - /// Makes a new MCP token for one client entry of `agent`'s, replacing - /// the one with the same label, and returns it. The agent's other - /// tokens keep working. Only its SHA-256 is kept. + /// Makes a new MCP token for one client entry of `agent`'s and returns + /// it with its id. Older tokens with the same label keep working until + /// [`Store::retire_older_connections`], so a client entry that fails + /// to be written leaves the one before it signed in. Only the token's + /// SHA-256 is kept. pub fn issue_connection_token( &mut self, agent: &str, label: &str, - ) -> Result { + ) -> Result<(String, String), StoreError> { use base64::Engine as _; let mut bytes = [0u8; 32]; getrandom::fill(&mut bytes).expect("the system has a random source"); @@ -725,8 +735,8 @@ impl Store { let tx = self .conn .transaction_with_behavior(TransactionBehavior::Immediate)?; - revoke_connections(&tx, |t| t.agent == agent && t.label == label)?; let info = ConnectionToken { + id: String::new(), agent: agent.to_owned(), label: label.to_owned(), created_ms: now_ms(), @@ -742,20 +752,70 @@ impl Store { ], )?; tx.commit()?; - Ok(token) + Ok((token, hash.chars().take(CONNECTION_ID_LEN).collect())) } - /// Removes the token of `agent`'s connection with this label. Returns - /// whether there was one. - pub fn revoke_connection_token( + /// Removes `agent`'s tokens with this label other than the one whose id + /// is `keep`: once the client entry holding `keep` is written. + pub fn retire_older_connections( &mut self, agent: &str, label: &str, + keep: &str, ) -> Result { let tx = self .conn .transaction_with_behavior(TransactionBehavior::Immediate)?; - let had = revoke_connections(&tx, |t| t.agent == agent && t.label == label)?; + let mut any = false; + for (hash, t) in connection_tokens(&tx)? { + if t.agent == agent && t.label == label && !hash.starts_with(keep) { + tx.execute( + "DELETE FROM settings WHERE key IN (?1, ?2)", + params![format!("token.{hash}"), format!("{CONNECTION}{hash}")], + )?; + any = true; + } + } + tx.commit()?; + Ok(any) + } + + /// Removes the one connection token of `agent`'s whose id is `id` (at + /// least its first [`CONNECTION_ID_LEN`] characters). Returns its label + /// if there was one. + pub fn revoke_connection( + &mut self, + agent: &str, + id: &str, + ) -> Result, StoreError> { + if id.len() < CONNECTION_ID_LEN { + return Ok(None); + } + let id = id.to_ascii_lowercase(); + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let found = connection_tokens(&tx)? + .into_iter() + .find(|(hash, t)| t.agent == agent && hash.starts_with(&id)); + if let Some((hash, _)) = &found { + tx.execute( + "DELETE FROM settings WHERE key IN (?1, ?2)", + params![format!("token.{hash}"), format!("{CONNECTION}{hash}")], + )?; + } + tx.commit()?; + Ok(found.map(|(_, t)| t.label)) + } + + /// Removes only the token `rowbit agents token` (or `rowbit connect` + /// before 0.2.3) made for `agent`, leaving its connections. Returns + /// whether it had one. + pub fn revoke_single_token(&mut self, agent: &str) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let had = revoke_token(&tx, agent)?; tx.commit()?; Ok(had) } @@ -766,7 +826,9 @@ impl Store { .into_iter() .map(|(_, t)| t) .collect(); - out.sort_by(|a, b| (&a.agent, &a.label).cmp(&(&b.agent, &b.label))); + out.sort_by(|a, b| { + (&a.agent, &a.label, a.created_ms).cmp(&(&b.agent, &b.label, b.created_ms)) + }); Ok(out) } @@ -778,11 +840,14 @@ impl Store { return Ok(None); }; let key = format!("{CONNECTION}{hash}"); - if let Some(value) = self.setting(&key)? { - let mut info: ConnectionToken = serde_json::from_str(&value) - .map_err(|e| StoreError::Corrupt(format!("{key}: {e}")))?; - info.used_ms = Some(now_ms()); - self.set_setting(&key, &serde_json::to_string(&info).expect("serializes"))?; + if self.setting(&key)?.is_some() { + // One statement, and only while the token still exists, so a + // revoke in another process never sees its row come back. + self.conn.execute( + "UPDATE settings SET value = json_set(value, '$.used_ms', ?1) + WHERE key = ?2 AND EXISTS (SELECT 1 FROM settings WHERE key = ?3)", + params![now_ms(), key, format!("token.{hash}")], + )?; return Ok(Some(agent)); } self.set_setting( diff --git a/crates/rowbit-store/src/tests.rs b/crates/rowbit-store/src/tests.rs index 86a4163..a6e74cf 100644 --- a/crates/rowbit-store/src/tests.rs +++ b/crates/rowbit-store/src/tests.rs @@ -550,12 +550,13 @@ fn agent_tokens_are_kept_as_hashes_and_replaced() { fn each_connection_has_a_token_of_its_own() { let mut store = Store::open_in_memory().unwrap(); let manual = store.issue_agent_token("bot").unwrap(); - let app = store + let (app, app_id) = store .issue_connection_token("bot", "Claude Code in ~/app") .unwrap(); - let web = store + let (web, web_id) = store .issue_connection_token("bot", "Claude Code in ~/web") .unwrap(); + assert_eq!(app_id.len(), CONNECTION_ID_LEN); // Each works, and none signs another out. for t in [&manual, &app, &web] { assert_eq!(store.agent_for_token(t).unwrap().as_deref(), Some("bot")); @@ -565,12 +566,20 @@ fn each_connection_has_a_token_of_its_own() { list.iter().map(|t| t.label.as_str()).collect::>(), ["Claude Code in ~/app", "Claude Code in ~/web"] ); + assert_eq!(list[0].id, app_id); assert!(list.iter().all(|t| t.used_ms.is_some())); - // Connecting the same place again replaces only that one. - let app2 = store + // Connecting the same place again: both work until the new entry is + // written, then only the new one. + let (app2, app2_id) = store .issue_connection_token("bot", "Claude Code in ~/app") .unwrap(); + assert_eq!(store.agent_for_token(&app).unwrap().as_deref(), Some("bot")); + assert!( + store + .retire_older_connections("bot", "Claude Code in ~/app", &app2_id) + .unwrap() + ); assert_eq!(store.agent_for_token(&app).unwrap(), None); assert_eq!( store.agent_for_token(&app2).unwrap().as_deref(), @@ -579,14 +588,33 @@ fn each_connection_has_a_token_of_its_own() { assert_eq!(store.agent_for_token(&web).unwrap().as_deref(), Some("bot")); assert_eq!(store.connection_tokens().unwrap().len(), 2); + // One connection, by its id; too short an id names none. + assert_eq!(store.revoke_connection("bot", &web_id[..4]).unwrap(), None); + assert_eq!(store.revoke_connection("other", &web_id).unwrap(), None); + assert_eq!( + store.revoke_connection("bot", &web_id).unwrap().as_deref(), + Some("Claude Code in ~/web") + ); + assert_eq!(store.agent_for_token(&web).unwrap(), None); + // A revoked token's row doesn't come back when it is tried. assert!( store - .revoke_connection_token("bot", "Claude Code in ~/web") + .connection_tokens() .unwrap() + .iter() + .all(|t| t.id != web_id) + ); + + // The single token alone, leaving the connections. + assert!(store.revoke_single_token("bot").unwrap()); + assert_eq!(store.agent_for_token(&manual).unwrap(), None); + assert_eq!( + store.agent_for_token(&app2).unwrap().as_deref(), + Some("bot") ); - assert_eq!(store.agent_for_token(&web).unwrap(), None); // Revoking the agent signs out every one. + let manual = store.issue_agent_token("bot").unwrap(); assert!(store.revoke_agent_token("bot").unwrap()); assert_eq!(store.agent_for_token(&manual).unwrap(), None); assert_eq!(store.agent_for_token(&app2).unwrap(), None); diff --git a/docs/connect.md b/docs/connect.md index b226979..e8f4bbd 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -35,7 +35,8 @@ With `mcp.http: true` in `rowbit-server.yml` (the demo has it), the client gets `rowbit connect` keeps a backup of any file it changes (`.rowbit-backup`), refuses a file it can't parse, and replaces nothing without being told: - `--force` replaces an entry of the same name; `--name ` adds another. -- Each client entry gets a token of its own, named for the client and, for Claude Code, the project: "Claude Code in ~/projects/my-app". Connecting the same place again (with `--force`) replaces only that one. `rowbit agents` lists them, and `rowbit agents revoke ` signs out all of an agent's. +- Each client entry gets a token of its own, named for the client and, for Claude Code, the project: "Claude Code in ~/projects/my-app". Connecting the same place again (with `--force`) replaces only that one, once the new entry is written. `rowbit agents` lists them with an id each; `rowbit agents revoke --connection ` signs out one, and `rowbit agents revoke ` all of them. Tokens don't expire, so revoke the ones for projects you've moved or deleted. +- `--new-token` also revokes the agent's single token from before 0.2.3, once no client uses it; without it, `rowbit connect` says when the agent still has one. - `--agent ` picks the agent when `rowbit.yml` lists more than one. - `--print` changes nothing in the client and prints what to add by hand, with the token. diff --git a/docs/spec/server.md b/docs/spec/server.md index 67c9228..0a41585 100644 --- a/docs/spec/server.md +++ b/docs/spec/server.md @@ -114,7 +114,7 @@ With `mcp.http: true`, `rowbit serve` also serves agents at `/mcp` o Each agent signs in with a token of its own, sent as `Authorization: Bearer `: -- `rowbit agents token ` makes a new token for an agent listed in `rowbit.yml`, prints it once, and keeps only its SHA-256 hash in the state file. Making a new one replaces the old. `rowbit connect` makes one per client entry instead, labelled with where it is (see [rowbit connect](setup.md#rowbit-connect)), so an agent can be connected in several places at once. `rowbit agents revoke ` removes all of the agent's tokens. `rowbit agents` lists each agent's tokens and when each was last used. +- `rowbit agents token ` makes a new token for an agent listed in `rowbit.yml`, prints it once, and keeps only its SHA-256 hash in the state file. Making a new one replaces the old. `rowbit connect` makes one per client entry instead, labelled with where it is (see [rowbit connect](setup.md#rowbit-connect)), so an agent can be connected in several places at once. `rowbit agents` lists each agent's tokens, each with an id and when it was last used. `rowbit agents revoke ` removes all of the agent's tokens, `--connection ` one of them, and `--single` only the one `rowbit agents token` made. - Tokens are 32 random bytes, written as `rbt_` and 43 characters of base64url. - The agent's identity is the token's agent, never anything in the request. A missing, unknown or revoked token, or a token for an agent no longer in `rowbit.yml`, gets `401` and nothing else. Failed sign-ins are slowed down as on the review page. - `/mcp` answers only requests addressed to the configured host, and refuses any request with an `Origin` header that isn't `public_url`, so a web page can't reach it from someone's browser. diff --git a/docs/spec/setup.md b/docs/spec/setup.md index 74ee9c1..4e38102 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -153,7 +153,7 @@ Compose names the project after the directory, so two demo directories with the - **The agent** is `--agent `, or the only agent in `rowbit.yml`. With more than one and no `--agent`, Rowbit lists their ids and stops. - **Over HTTP** when `rowbit-server.yml` has `mcp.http: true`. The address is `/mcp`, or `http:///mcp` without a `public_url`. Rowbit makes the agent a token as `rowbit agents token` does and writes it into the client's configuration as `Authorization: Bearer `; the client's machine gets nothing else. If nothing answers at the address, it says to start `rowbit serve`. - **Over stdio** otherwise, with `--stdio`, and always for Claude Desktop. The client starts `rowbit mcp --agent ` with full paths to the binary, `rowbit.yml` and `rowbit.db`, and `ROWBIT_DATABASE_URL` from the environment, or from `.rowbit-demo` in the demo directory. Rowbit prints that the client's configuration now holds the database URL and points at the state file, and that anyone who can read them can act around Rowbit (see [Before you use it for real](../quickstart.md#before-you-use-it-for-real)). -- **One token per client entry.** Over HTTP, each entry gets a token of its own, labelled with the client and, for Claude Code's local scope, the directory: "Claude Code in ~/projects/app", "Claude Code, every project", "Cursor". A new token for the same agent and label replaces the old one; the agent's other tokens keep working. `--print` labels its token "(printed)", so it never signs out an entry `rowbit connect` added. `--new-token`, which v0.2.2 needed to replace an agent's only token, is accepted and does nothing. +- **One token per client entry.** Over HTTP, each entry gets a token of its own, labelled with the client and, for Claude Code's local scope, the directory: "Claude Code in ~/projects/app", "Claude Code, every project", "Cursor". A new token for the same agent and label replaces the old one, which is revoked only once the client's entry is written, so a failed `connect` leaves the entry that was there working; the agent's other tokens keep working. `--print` labels its token "(printed