diff --git a/.gitignore b/.gitignore index 1b2ae933..a79024a0 100644 --- a/.gitignore +++ b/.gitignore @@ -227,6 +227,8 @@ __marimo__/ # otherwise record as gitlinks to commits no clone of this one can resolve. .claude/ -# The macOS app: what pnpm installs, and the schemas tauri-build writes on every build. +# The macOS app: what pnpm installs, and the schemas and the permissions of its own commands +# tauri-build writes on every build. desktop/node_modules/ desktop/src-tauri/gen/ +desktop/src-tauri/permissions/autogenerated/ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 10e7203b..b485f2df 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1477,10 +1477,14 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" name = "humanize" version = "0.0.0" dependencies = [ + "block2", "flate2", "libc", "log", + "objc2", "objc2-core-foundation", + "objc2-foundation", + "objc2-user-notifications", "semver", "serde", "serde_json", @@ -2366,6 +2370,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.13.2", + "block2", "objc2", "objc2-foundation", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 93aedca7..0eb718c8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -27,6 +27,11 @@ libc = "0.2" log = "0.4" # The update's alerts: CFUserNotification, which never holds up a quit as a sheet would. objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFDate", "CFDictionary", "CFString", "CFURL", "CFUserNotification"] } +# Notifications the page asks for: UNUserNotificationCenter, and a delegate told of a click. +block2 = { version = "0.6", default-features = false, features = ["std"] } +objc2 = { version = "0.6", default-features = false, features = ["std"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSError", "NSObject", "NSString"] } +objc2-user-notifications = { version = "0.3", default-features = false, features = ["std", "bitflags", "block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSound", "UNNotificationTrigger", "UNUserNotificationCenter"] } semver = "1" serde = { version = "1", features = ["derive"] } # What generate_context! writes the updater's configuration out with. diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 261851f6..235af997 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,9 @@ +/// The app's commands, each granted by a capability: `notify` to `hmz web`'s page, the rest to +/// the app's own. +const COMMANDS: &[&str] = &["status", "retry", "keep", "update_now", "notify"]; + fn main() { - tauri_build::build(); + let manifest = tauri_build::AppManifest::new().commands(COMMANDS); + tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest)) + .expect("failed to run the Tauri build script"); } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 818e7b8b..02e0605e 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,7 +1,14 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "The app's own page: its own commands need no permission, and it is granted only to drag and zoom the window.", + "description": "The app's own page: granted its own commands, which say and change how hmz web starts, and to drag and zoom the window.", "windows": ["main"], - "permissions": ["core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"] + "permissions": [ + "allow-status", + "allow-retry", + "allow-keep", + "allow-update-now", + "core:window:allow-start-dragging", + "core:window:allow-internal-toggle-maximize" + ] } diff --git a/desktop/src-tauri/capabilities/page.json b/desktop/src-tauri/capabilities/page.json index ef5c64cf..14239428 100644 --- a/desktop/src-tauri/capabilities/page.json +++ b/desktop/src-tauri/capabilities/page.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "page", - "description": "hmz web's page, on whichever port of this machine the app started it, the window loading no other: granted only to drag and zoom the window by its top row, and none of the app's commands.", + "description": "hmz web's page, on whichever port of this machine the app started it, the window loading no other: granted only to post a notification, and to drag and zoom the window by its top row.", "windows": ["main"], "remote": { "urls": ["http://127.0.0.1:*"] }, - "permissions": ["core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"] + "permissions": ["allow-notify", "core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ba491b4b..3fecf5da 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod child; mod chrome; mod install; mod menu; +mod notify; mod server; mod shell_env; mod update; @@ -92,6 +93,8 @@ struct Shell { page: Mutex>, /// An epic's archive opened before then, imported once it is set. opening: Mutex>, + /// The address of a notification clicked before then, opened once it is set. + notified: Mutex>, } /// Where `hmz web` stands, once it is anything but starting, and other than the page drew last. @@ -106,6 +109,13 @@ async fn status(after: Option, shell: State<'_, Shell>) -> Result) { + notify::post(&title, &body, address.as_deref().unwrap_or_default()); +} + /// Starts again where it stopped short: installing `hmz`, then `hmz web`. #[tauri::command] fn retry(app: AppHandle, shell: State<'_, Shell>) { @@ -205,7 +215,9 @@ fn start(app: AppHandle, how: How) { *lock(&shell.page) = Some(page.clone()); let opening = lock(&shell.opening).take(); let opening = opening.and_then(|path| importing(&page, &path)); - show(app, opening.unwrap_or(page)); + let notified = lock(&shell.notified).take(); + let notified = notified.and_then(|address| notify::target(&page, &address)); + show(app, opening.or(notified).unwrap_or(page)); }); // Told once `hmz web` stops on its own; nothing where the app stopped it. if let Some(said) = server.exited() { @@ -454,12 +466,16 @@ pub fn run() { downloads: Mutex::default(), page: Mutex::default(), opening: Mutex::default(), + notified: Mutex::default(), }) .menu(menu::build) .on_menu_event(menu::handle) - .invoke_handler(tauri::generate_handler![status, retry, keep, update_now]) + .invoke_handler(tauri::generate_handler![ + status, retry, keep, update_now, notify + ]) .setup(|app| { window(app.handle())?; + notify::init(app.handle()); start(app.handle().clone(), How::Usual); update::init(app.handle())?; Ok(()) diff --git a/desktop/src-tauri/src/notify.rs b/desktop/src-tauri/src/notify.rs new file mode 100644 index 00000000..83723686 --- /dev/null +++ b/desktop/src-tauri/src/notify.rs @@ -0,0 +1,262 @@ +//! A notification as macOS posts one for an app, through `UNUserNotificationCenter`: what +//! `hmz web`'s page asks for when an epic ends, a flow asks, or a chat turn is done while the +//! window is not looked at. Clicking one brings the window forward, and opens on `hmz web`'s +//! page the address it was given -- a hash of that page, and never anywhere else. + +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use block2::{DynBlock, RcBlock}; +use objc2::rc::Retained; +use objc2::runtime::{Bool, NSObject, NSObjectProtocol, ProtocolObject}; +use objc2::{AnyThread, define_class, msg_send}; +use objc2_foundation::{NSError, NSString}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNMutableNotificationContent, UNNotification, + UNNotificationPresentationOptions, UNNotificationRequest, UNNotificationResponse, + UNNotificationSound, UNUserNotificationCenter, UNUserNotificationCenterDelegate, +}; +use tauri::AppHandle; +use url::Url; + +use crate::{lock, on_main, reveal, show}; + +/// The longest address a notification opens: one of the page's own is far shorter. +const LONGEST: usize = 2048; + +/// The app, for a notification clicked. +static APP: OnceLock = OnceLock::new(); + +/// Notifications posted this launch, numbering each apart from the others. +static POSTED: AtomicU64 = AtomicU64::new(0); + +define_class!( + // SAFETY: NSObject has no subclassing requirements, and the delegate keeps no state. + #[unsafe(super(NSObject))] + #[name = "HumanizeNotifications"] + struct Delegate; + + unsafe impl NSObjectProtocol for Delegate {} + + unsafe impl UNUserNotificationCenterDelegate for Delegate { + /// Shown even while the app is in front: the page asks only while its window is not + /// looked at. + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + fn will_present( + &self, + _center: &UNUserNotificationCenter, + _notification: &UNNotification, + handler: &DynBlock, + ) { + handler.call((UNNotificationPresentationOptions::Banner + | UNNotificationPresentationOptions::List + | UNNotificationPresentationOptions::Sound,)); + } + + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + handler: &DynBlock, + ) { + let identifier = response.notification().request().identifier().to_string(); + clicked(address(&identifier).to_owned()); + handler.call(()); + } + } +); + +/// Listens for notifications clicked, those of an earlier launch too. On the main thread, as +/// the app sets up. +pub fn init(app: &AppHandle) { + let _ = APP.set(app.clone()); + if !bundled() { + return; + } + // SAFETY: NSObject's own init, on an instance just allocated. + let delegate: Retained = unsafe { msg_send![Delegate::alloc(), init] }; + UNUserNotificationCenter::currentNotificationCenter() + .setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + // The centre holds its delegate weakly: this one is kept for as long as the app runs. + std::mem::forget(delegate); +} + +/// Posts a notification, asking the first time whether the app may. +pub fn post(title: &str, body: &str, address: &str) { + // Kept with the notification for as long as macOS keeps it: only what a click may open. + let address = hash(address).map_or("", |_| address); + if !bundled() { + log::info!("a notification is not posted outside the app's bundle"); + return; + } + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(title)); + content.setBody(&NSString::from_str(body)); + content.setSound(Some(&UNNotificationSound::defaultSound())); + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &NSString::from_str(&identifier(address)), + &content, + None, + ); + let center = UNUserNotificationCenter::currentNotificationCenter(); + let posting = center.clone(); + // Asked once: macOS answers at once from then on, as the user last said. + let posted = RcBlock::new(|error: *mut NSError| { + // SAFETY: an NSError the centre made and holds through the call, or none. + if let Some(error) = unsafe { error.as_ref() } { + log::warn!("a notification could not be posted: {error}"); + } + }); + let granted = RcBlock::new(move |granted: Bool, error: *mut NSError| { + // SAFETY: as above. + if let Some(error) = unsafe { error.as_ref() } { + log::warn!("notifications could not be asked for: {error}"); + } else if granted.as_bool() { + posting.addNotificationRequest_withCompletionHandler(&request, Some(&posted)); + } else { + log::info!("notifications are not allowed"); + } + }); + center.requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &granted, + ); +} + +/// Brings the window forward, on `hmz web`'s page at the address clicked where it is one: on +/// the page it shows, by its hash alone; or, where it shows none yet -- a click that launched +/// the app -- on the page it opens once there is one. +fn clicked(address: String) { + let Some(app) = APP.get() else { return }; + on_main(app, move |app, shell| { + let page = lock(&shell.page).clone(); + match page { + Some(mut page) => { + // Signed in already: the hash changes, and the page is not loaded again. + page.set_query(None); + if let Some(url) = target(&page, &address) { + show(app, url); + } + } + None => *lock(&shell.notified) = Some(address), + } + reveal(app); + }); +} + +/// Whether the app runs from its bundle, `….app/Contents/MacOS/…`: the notification centre +/// throws for a process that is not, as one `cargo` runs. +fn bundled() -> bool { + std::env::current_exe().is_ok_and(|exe| in_bundle(&exe)) +} + +fn in_bundle(exe: &std::path::Path) -> bool { + let mut up = exe.ancestors().skip(1); + up.next().and_then(|dir| dir.file_name()) == Some("MacOS".as_ref()) + && up.next().and_then(|dir| dir.file_name()) == Some("Contents".as_ref()) + && up + .next() + .and_then(|dir| dir.extension()) + .is_some_and(|ext| ext == "app") +} + +/// A notification's identifier: unique, so that none replaces another, and carrying the address +/// it opens, so that one of an earlier launch opens it too. +fn identifier(address: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let n = POSTED.fetch_add(1, Ordering::Relaxed); + format!("{now}.{n} {address}") +} + +/// The address a notification's identifier carries. +fn address(identifier: &str) -> &str { + identifier + .split_once(' ') + .map_or("", |(_, address)| address) +} + +/// The hash a notification may open, `#/…`, without its `#`; nothing for anything else. +fn hash(address: &str) -> Option<&str> { + address.strip_prefix('#').filter(|hash| { + hash.starts_with('/') && address.len() <= LONGEST && !hash.chars().any(char::is_control) + }) +} + +/// Where a notification clicked opens: the hash it was given, `#/…`, at the root of `hmz web`'s +/// page -- its origin, and its key where it is given one -- and nowhere else; nothing for +/// anything but such a hash. +pub fn target(page: &Url, address: &str) -> Option { + let hash = hash(address)?; + let mut url = page.clone(); + url.set_path("/"); + url.set_fragment(Some(hash)); + Some(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opens_a_hash_of_the_servers_page_alone() { + let server = Url::parse("http://127.0.0.1:8765/").unwrap(); + let at = |address: &str| target(&server, address).map(String::from); + assert_eq!( + at("#/@gpu/live?workspace=%2Fhome%2Fme").as_deref(), + Some("http://127.0.0.1:8765/#/@gpu/live?workspace=%2Fhome%2Fme") + ); + assert_eq!(at("#/").as_deref(), Some("http://127.0.0.1:8765/#/")); + // From the address with the key, as the window first opens on, keeping it to sign in. + let first = Url::parse("http://127.0.0.1:8765/?key=secret#/epics").unwrap(); + assert_eq!( + target(&first, "#/@local/live").map(String::from).as_deref(), + Some("http://127.0.0.1:8765/?key=secret#/@local/live") + ); + // Never another origin, path or scheme. + for address in [ + "", + "#", + "#epics", + "/#/epics", + "epics", + "http://evil.example/#/epics", + "//evil.example/#/epics", + "javascript:alert(1)", + "#/epics\n", + "#/\u{7f}", + ] { + assert_eq!(at(address), None, "{address:?}"); + } + assert_eq!(at(&format!("#/{}", "a".repeat(LONGEST))), None); + // Whatever the hash says, the origin stays the server's. + let odd = target(&server, "#/x#@evil.example:80/ y").unwrap(); + assert_eq!(odd.origin(), server.origin()); + assert_eq!(odd.path(), "/"); + assert_eq!(odd.query(), None); + } + + #[test] + fn carries_its_address_in_a_unique_identifier() { + let one = identifier("#/@local/live?workspace=a b"); + let two = identifier("#/@local/live?workspace=a b"); + assert_ne!(one, two); + assert_eq!(address(&one), "#/@local/live?workspace=a b"); + assert_eq!(address(&identifier("")), ""); + assert_eq!(address("elsewhere"), ""); + } + + #[test] + fn posts_only_from_its_bundle() { + let at = |path: &str| in_bundle(std::path::Path::new(path)); + assert!(at("/Applications/humanize.app/Contents/MacOS/humanize")); + assert!(!at("/repo/desktop/src-tauri/target/debug/humanize")); + assert!(!at("/x/Contents/MacOS/humanize")); + assert!(!at("/x.app/MacOS/humanize")); + assert!(!at("humanize")); + } +} diff --git a/docs/features/surfaces.md b/docs/features/surfaces.md index 0db52832..516032d6 100644 --- a/docs/features/surfaces.md +++ b/docs/features/surfaces.md @@ -21,6 +21,11 @@ import { withBase } from 'vitepress' - **Walk away.** [The terminal can leave](/features/daemon), and the run keeps going. - **Two windows, one run.** A browser on [`hmz web`](/user/web) and a terminal on `hmz` read and drive the same run, and each says who did what. +- **Many machines, one page.** The page reaches the [daemons](/user/web#daemons) of your ssh + hosts as it does this machine's, one at a time, and installs the same humanize where one is + missing. +- **Told when it matters.** An epic ending, a question for you or a `chat` waiting, on any of + them, is a [notification](/user/web#notifications): in the page, the browser or macOS. - **Settings become a form.** The terminal interface draws one from a flow's [settings](/weaver/flow-settings). Its author writes none of it. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f3d0230e..f2f5aafb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -585,7 +585,7 @@ to it, as `uv tool upgrade` holds it; uv says how to lift the pin. Epics a [daem under the release it started with until it stops; where one is running, `hmz update` says so and leaves it alone. The interfaces say when a newer release is out: the [terminal interface](/reference/tui) in a line as it opens, the [web interface](/reference/web) with -`Update` in the machine menu. +`Update` in the daemon picker. | Condition | Output | Exit | | --- | --- | --- | diff --git a/docs/reference/daemon.md b/docs/reference/daemon.md index fd903fb0..643c8c83 100644 --- a/docs/reference/daemon.md +++ b/docs/reference/daemon.md @@ -247,6 +247,9 @@ the daemon answers it ([Routing](#routing)). `Daemon.asked(said)` sends one with | `state` | string | `running`, `stopping` or `idle`. | | `run` | int | The run going, else the one stopping, else the last; `0` for none. | | `flow` | string | That run's flow, or `""`. | +| `epic`, `task` | string | That run's epic (its directory's name, `""` before it has one) and task, or `""`. | +| `pending` | list | `[{question, role, text, mode}]`: the questions waited on, as the [`pending` snapshot](#snapshots) says them. | +| `ended` | object \| null | How the last run to end here ended: `how` (`done`, `stopped`, `refused`, `budget`, `failed` or `crashed`), `why`, `epic`, `at` (Unix seconds), `run`, `flow`, `task`; `null` before one has. | | `budget`, `usage` | object \| null | As JSON, for the run going; `null` while stopping or idle. An infinite cost is the JSON string `"Infinity"`. | | `flows` † | list[string] | Canonical refs of the flow calls in progress. | | `calls` † | list | `[{ref, name, depth, seconds, id, parent}]`, oldest-started first across every run in the process; `seconds` rounded to 0.001; `parent` the index of the calling entry, or `null`. | diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 2c7b1d6c..0626d5b4 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -46,6 +46,17 @@ enable_sentry: false # this machine details: true # this machine btw: claude/claude-opus-5:high # this machine every_epic: true # this machine +daemons: # this machine; Settings › Daemons in the web interface +- name: gpu + host: gpu.example + user: me + port: 22 + identity_file: ~/.ssh/id_ed25519 + proxy_jump: "" + options: [[ServerAliveInterval, "30"]] +notifications: # this machine; Settings › Notifications in the web interface + kinds: {done: true, failed: true, stopped: true, asked: true, listening: true} + system: true spelling: 2 # how envs are spelled; written with every change fallbacks: # this machine; the Fallback page - spec: claude@work/claude-opus-5 @@ -76,6 +87,8 @@ backup: # this machine | `details` | `bool` (only `true` is on) | `false` | `/settings` › General › **Details** | the TUI | immediately: turns show every tool call and all thinking instead of responses only | | `btw` | `str`: `cli[@account]/model:effort` | `""`: the running flow's first agent | `/settings` › General › **/btw agent** | the TUI | the next time `/btw` is entered | | `every_epic` | `bool` (only `true` is on) | `false`: this directory's | `ctrl+t` on [`/epics`](/reference/tui#the-runs-that-have-already-happened) | the TUI | the next time `/epics` is opened | +| `daemons` | list of `{name, host, user, port, identity_file, proxy_jump, options: [[keyword, value]], alias, config}` | `[]`: Local alone | the web interface's Settings › Daemons, [`POST /api/daemons`](/reference/web#api-settings) and its `import` | `hmz web` | at once: a daemon added is reached the first time the page asks for it, one removed is let go of | +| `notifications` | `{kinds: {done, failed, stopped, asked, listening}: bool, system: bool}` | every kind told, and shown by the system too; only `false` turns one off | the web interface's Settings › Notifications, [`POST /api/notifications/settings`](/reference/web#api-notifications) | `hmz web` | immediately: the next notice of a kind is told or not | | `spelling` | `int` | absent: written before `-e` wrote `@` only before a provider | every write of the file, as `2` | every process, as it reads the file | absent, `envs` are read as their old spelling meant and rewritten once; `2`, as written | What error reports contain and exclude is listed on `/settings` › General › **What is sent**. diff --git a/docs/reference/web.md b/docs/reference/web.md index a7d731f4..a9ddf1f1 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -143,7 +143,16 @@ Each view is an address after `#`, so it can be kept and opened again: | `#/flows?tab=verses[&verse=]` | The flowverses, one opened. | | `#/search[?status=&flow=&q=&offset=]` | A dialog finding epics. `status`: how it went, `running` or `unfinished`. | | `#/download[?target=]` | A dialog downloading an epic out of a backup target: the first in settings without `target`. | -| `#/settings/[?days=]` | A dialog: `general`, `themes`, `accounts`, `fallback`, `runtimes`, `backup`, `workspace` or `usage`; `days` is how far `usage` looks back (7, 30, 90 or 365; default 30), by UTC day. | +| `#/settings/[?days=]` | A dialog: `general`, `themes`, `accounts`, `fallback`, `runtimes`, `backup`, `workspace`, `usage`, `daemons` or `notifications`; `days` is how far `usage` looks back (7, 30, 90 or 365; default 30), by UTC day. | + +Every address above is of one daemon. `#/@/` -- `#/@local/new`, `#/@gpu/epics/` +-- is that view of the daemon named; an address naming none is Local's, as every address +written before daemons were is. The page shows only the daemon its address names: every link +it draws keeps the `@` the address carries, and going to another daemon's address draws +the view and the sidebar again, of that daemon, closing what the page streamed of the last. +Everything the page asks of a daemon is under `/d//api/…`, Local's `/d/local/api/…` +being the same as `/api/…`; what is the same whichever daemon the page is on -- +`/api/daemons…` and `/api/notifications…` -- is asked of Local, unscoped. The page keeps a few conveniences in the browser's `localStorage`, each forgotten without harm: the colours chosen as `hmz-theme` (`light`, `dark`, or absent for the system's), a sidebar @@ -275,9 +284,62 @@ Every `error` but the last is the runtime's own sentence, e.g. | `POST /api/runtimes/check` | `backend`, `name` | What it said: `reached`, `said`, `home`, `cpus`, `memory`, `gpus`, `usable`, `gpu_memory`, `runtimes`, `version`, `short`, `nodes` | | `GET /api/runtimes/hosts` | | `hosts` your ssh config names (`alias`, `host`, `user`, `port`, `identity_files`, `proxy_jump`, `saved`) | | `POST /api/runtimes/import` | `names`; `update` (bool) | As `GET /api/runtimes` | +| `GET /api/daemons` | | `daemons`: `Local` first, then each ssh daemon (`name`, `label`, `host`, `user`, `port`, `identity_file`, `proxy_jump`, `options` as `[keyword, value]` pairs, `alias`, `config`) with `state`, `detail` and `version` | +| `POST /api/daemons` | `name`, `fields`; `replace` (bool) | As `GET` | +| `POST /api/daemons/remove` | `name` | As `GET` | +| `GET /api/daemons/hosts` | | `hosts` your ssh config names that are not daemons yet (`alias`, `name`, `host`, `user`, `port`, `identity_files`, `proxy_jump`) | +| `POST /api/daemons/import` | `aliases` | As `GET`, and `added`: the daemons made, skipping any already there | +| `GET /api/daemons/{name}/state` | | `state` (`idle`, `connecting`, `installing`, `up` or `failed`), `detail` (what was said last, such as uv's last line) and `version` (its `hmz`'s) | +| `POST /api/daemons/{name}/connect` | | As `GET …/state`, once it is being reached: its `hmz` installed at this version where none or another is and it holds no running epics, `hmz web --no-open --attached` started there over ssh, and a port of this machine forwarded to it | +| any `/d/{name}/api/…` | as `/api/…` there | What that daemon's own interface answers, passed through as it comes -- a stream included -- signed in with its key, which no page sees; `503` while it is not up, `404` for a daemon not written down | An account is listed by the names of the variables it sets (`sets`), never by a value. +### Notices {#api-notices} + +What happened to this machine's runs worth telling somebody about. They are worked out of each +workspace's host [`status`](/reference/daemon#status), asked every **2 s** without attaching to +any host, so a host goes as it would with nobody reading. Anything already standing when +`hmz web` starts is not news. + +| Method and path | Takes | Answers | +| --- | --- | --- | +| `GET /api/notices` | `after` (an `id`; `0`, the default, for every one kept) | `notices` (those kept after it, oldest first; the last **200**), `last` (the newest `id`, `0` for none); `400` where `after` is not a whole number | +| `GET /api/notices/stream` | `Last-Event-ID` header, or `?last=` | `text/event-stream`: each `notice` event's `id` is its `id` and its data the notice, from after the one named (without one, from now on), then each as it comes; `: quiet` after **15 s** of nothing | + +A notice is `id` (a whole number, larger than any before it, on this machine too after +`hmz web` starts again), `kind`, `workspace`, `epic`, `flow`, `task`, `role`, `text` and `at` +(Unix seconds): + +| `kind` | When | `role`, `text` | +| --- | --- | --- | +| `done` | An epic ended done | `""`, `""` | +| `failed` | An epic ended failed, crashed, refused or over its budget | `""`, why | +| `stopped` | An epic was stopped | `""`, `""` | +| `asked` | A flow asks a person a question (mode `ask`) | The role asking, the question | +| `listening` | A flow waits for what to say next: a chat between turns (mode `listen`) | The role asking, what it asks | + +### Notifications {#api-notifications} + +What every daemon's notices come to, served by the interface a page is open on alone: its own +machine's as they are heard, and each SSH daemon's while it is reached, off the +`/api/notices/stream` that daemon's own interface serves -- from what it says once first +reached, and from where it was last heard after that. A notification is the notice, with its own +`id` (counted from `1` each time `hmz web` starts), `daemon` (`local` or the daemon's name) and +`read`. The last **200** are kept, in memory. + +| Method and path | Takes | Answers | +| --- | --- | --- | +| `GET /api/notifications` | | `items` (newest first), `unread` (how many are not read) | +| `GET /api/notifications/stream` | `Last-Event-ID` header, or `?last=` | `text/event-stream`: a `notification` event, `id` its `id`, for each kept after the one named -- every one kept where it names one later than any here -- then each as it comes; with none, from now. `: quiet` after **15 s** of nothing | +| `POST /api/notifications/read` | `ids` (a list), or `all: true` | As `GET` | +| `POST /api/notifications/clear` | | As `GET`, with nothing kept | +| `GET /api/notifications/settings` | | `kinds` (`{done, failed, stopped, asked, listening}`: whether each is told) and `system` (whether the page asks the system to show them too), as [`notifications`](/reference/settings#machine-settings) holds them | +| `POST /api/notifications/settings` | any of `kinds` (any of those five, each a bool), `system` (bool) | As `GET`; `400`, and nothing written, for anything else | + +A notice of a kind turned off is never kept nor sent. Which kinds are told is read afresh for +every notice, so a change takes effect at once. + ## The stream {#stream} `GET /api/held/stream` answers `text/event-stream`, starting with `retry: 2000`. Each event is diff --git a/docs/user/backups.md b/docs/user/backups.md index 48aaf2cf..ed8aa9ef 100644 --- a/docs/user/backups.md +++ b/docs/user/backups.md @@ -122,7 +122,7 @@ same path, on the commit it was on. An epic you already have -- one this machine still keeps, or one downloaded before -- is refused. Without `--to`, every target is looked in; where two machines' epics share a name, name it as `//`. -In [`hmz web`](/user/web), **Download** in the machine's menu lists what a +In [`hmz web`](/user/web), **Download** in the daemon picker lists what a target holds by machine and then by directory, the ones already here marked **Here**; pick the target where there is more than one. **Download** on an epic brings it here and opens it. diff --git a/docs/user/desktop.md b/docs/user/desktop.md index 75f28a5d..a8e8ce1b 100644 --- a/docs/user/desktop.md +++ b/docs/user/desktop.md @@ -57,7 +57,13 @@ The app is a window, not a second humanize. It runs `hmz` as sidebar's top. Drag the window by any empty part of the top row, and double-click it to zoom. - **Links to elsewhere open in your browser**, and an export is saved to **Downloads** and shown in the Finder. +- **Other machines too.** The [daemons](/user/web#daemons) the page reaches -- your ssh hosts + -- are reached from the app as from a browser, with your ssh config, and switched between at + the foot of the sidebar. - **It updates itself.** See [Updates](#updates). +- **It notifies you as a Mac app does.** When an epic ends, a flow asks you something, or a chat + turn is done while you are not looking at the window, the page's notification shows in the + corner of the screen and in Notification Center. See [Notifications](#notifications). ## Opening a `.hmzepic` {#opening-a-hmzepic} @@ -70,6 +76,18 @@ at once, the last is imported; anything but a `.hmzepic` is left alone. One you already have is refused, with **Open** to go to it; one that cannot be picked up on this Mac says why on its own page, as any [imported epic](/user/web#importing-an-epic) does. +## Notifications {#notifications} + +The page's [notifications](/user/web#notifications) -- an epic done, failed or stopped, a +question for you, a `chat` waiting for your next line, on any daemon -- show in the window's bell +and toasts as in a browser, and, while the window is hidden or not in front, as macOS +notifications from humanize. Click one: the window comes to the front, on the epic it is about. + +The kinds that appear, and whether they are macOS notifications at all, are set in +**Settings → Notifications**; the app needs no permission asked there, as a browser does. The +first one asks whether humanize may send them: choose **Allow**. After that, macOS's own +**System Settings → Notifications → humanize** decides how they are shown, or silences them. + ## Updates {#updates} The app looks for a new release a few seconds after it opens, and every 6 hours after. It @@ -143,6 +161,12 @@ open ~/Library/Logs/ai.humanfia.humanize/humanize.log `hmz: the runs … are held by an older humanize` is the most common: an older `hmz` holds them. See [`hmz web`'s troubleshooting](/user/web#troubleshooting). +### No macOS notifications + +They come only while the window is hidden or not in front. Check that **Settings → +Notifications** in the app has them switched on, and that **System Settings → Notifications → +humanize** allows them. + ### The window opened on a page that forgot what I chose The page keeps your choices per port, and the port it had was taken by something else this diff --git a/docs/user/settings.md b/docs/user/settings.md index 623e23e4..8903b270 100644 --- a/docs/user/settings.md +++ b/docs/user/settings.md @@ -121,6 +121,10 @@ opens Workspace, and `environments` opens Runtimes. The [flowverses](#flowverses `/flow` now, and `/settings flowverses` opens them there. A card marked `● unsaved` holds a change you have not saved yet. +The [web page](/user/web#settings) has the same pages, under **Daemon**, for whichever daemon +it reaches -- this machine or an ssh host -- with **Batch apply** to copy one to several; its +**App** pages, themes, daemons and notifications, belong to the page and this machine alone. + ### How a page is laid out {#layout} Inside a page, the line across the top says where you are (`hmz › /settings › Accounts`), diff --git a/docs/user/web.md b/docs/user/web.md index ba8caa79..b9091ba5 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -4,11 +4,13 @@ [daemon](/features/daemon) holds every directory's runs: each epic going as it happens, every epic written down, starting one in any directory, what they spent, and what humanize remembers. It is one more window onto the runs `hmz` shows, not a copy of them. Start an epic in the -terminal and answer it in the browser, or the other way round. +terminal and answer it in the browser, or the other way round. The same page reaches the epics of +[other machines](#daemons) over ssh, one at a time, and [tells you](#notifications) when an epic +on any of them ends or asks you something. ::: info At a glance -- **You will** watch, steer and start epics in any directory from a browser, read an epic - written down, and see what they spent. +- **You will** watch, steer and start epics in any directory from a browser, on this machine or + an ssh host, read an epic written down, see what they spent, and be told when one needs you. - **Use it when** a picture of a run reads better than a transcript: its agents and who handed to whom, each session's turns laid out over time, a week's spend by flow. - **You need** humanize installed as in [Installation](/user/installation), and a browser on the @@ -65,13 +67,17 @@ lists its chats, and an epic opens out into its agents and the sessions each ope | **Search epics** (⌘K) | Every epic, with its directory, agents, what it spent and how long it took | Find epics by how they went, by directory, by flow, or by a word of their task; **Import** one. See [Importing an epic](#importing-an-epic) | | **Settings** | What humanize remembers, and what the epics here spent | Change it, as [`/settings`](/user/settings) does | -The foot of the sidebar names this machine and says how many epics are going on it; -its menu opens **Import**, **Settings**, **Usage** and **Download**, which downloads an epic out of a -[backup target](/user/backups#downloading-an-epic). Where a newer humanize is out, the machine is marked ↑ and its menu offers -**Update**, which copies `hmz update` to run in a terminal. ⇧⌘O opens a new epic and ⌘. folds the sidebar away. On a phone +The foot of the sidebar is the [daemon bar](#daemon-bar): it names the daemon the page shows, +its host and whether it is reached, and says how many epics are going on it. Its menu picks +another daemon, and opens **Import**, **Settings**, **Usage** and **Download**, which downloads an epic out of a +[backup target](/user/backups#downloading-an-epic). Where a newer humanize is out, the bar is marked ↑ and its menu offers +**Update**, which copies `hmz update` to run in a terminal. The bell at the top of the sidebar +opens your [notifications](#notifications). ⇧⌘O opens a new epic and ⌘. folds the sidebar away. On a phone the sidebar is a drawer behind the button in the corner. -Every view is an address: copy it, keep it, or open it in another tab. +Every view is an address: copy it, keep it, or open it in another tab. The address names the +daemon it shows, `#/@devbox/epics/…`, so a kept address opens on the same machine; one with no +`@` is Local's. **Right-click** anything for what can be done to it: a folder in the sidebar (new epic here, its flows, search it, copy its path, settings), an epic (open, its log, resume, copy its link or task, @@ -212,9 +218,10 @@ tokens, and every process -- click one to pick it. ### Importing an epic {#importing-an-epic} -Somebody sent you a `.hmzepic`? Press **Import** in **Search epics**, or in the machine's menu at -the foot of the sidebar, or right-click between the epics in the sidebar, and pick the file -- -or drop it anywhere on the window. It is read back in under the directory it ran in, and opens +Somebody sent you a `.hmzepic`? Press **Import** in **Search epics**, or in the daemon bar's +menu at the foot of the sidebar, or right-click between the epics in the sidebar, and pick the file -- +or drop it anywhere on the window. It is read back in on the daemon the page shows, under the +directory it ran in, and opens once it is in. One you already have is refused, with **Open** to go to it. An imported epic is read as any other. It is picked up only where the directory it ran in is @@ -245,20 +252,39 @@ directory last set it up: needs at least one limit. The page goes to the epic once it starts -- a `chat` to its conversation. -### Settings +### Settings {#settings} -The same pages as [`/settings`](/user/settings), in the same order, and what the epics spent: +Settings has two groups. **App** is the page's own: how it is drawn, the daemons it reaches and +what it tells you of them. It is the same whichever daemon is on screen. **Daemon** is the same +pages as [`/settings`](/user/settings), in the same order, and what the epics spent, all of one +daemon: | Page | What is there | | --- | --- | -| **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) | | **Themes** | The [themes](#themes) of this browser, and which two **Auto** follows your system with | +| **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) | | **Accounts** | Every [account](/user/settings#accounts) by its CLI, way in and the names of what it sets: ask what it runs again, **Edit** it (what you leave blank stays as it was), copy it to another CLI, remove it, or make one | | **Fallback** | Each [step](/user/settings#fallback): where a turn goes when a place cannot take it, and how it is tried again | | **Runtimes** | Every runtime: add one from its backend's fields, change, check or remove it, or bring hosts in from your ssh config | | **Backup** | Every [backup target](/user/backups): its repository and branch, when an epic goes to it (**On session close**, **Every N minutes**, **On epic end**, or none: by hand only), and whether it is a **Default**; add, edit or remove one. A URL is shown with nothing signed into it | | **Workspace** | What a directory was last set up with, and forgetting it -- which also takes it out of the sidebar where no epic ran there | | **Usage** | What the epics spent, of every directory or of one, by day and by flow, looking back 7, 30, 90 or 365 days | +| **Daemons** | **Local**, always there, and every SSH daemon with its state (up, connecting, installing, failed or idle): add or **Edit** one a part at a time (name, host, user, port, identity file, proxy jump, SSH options), **Import** hosts from your SSH config, **Connect** or **Remove** one, asked first | +| **Notifications** | Which notifications appear, of every daemon: **Done**, **Failed**, **Stopped**, **Questions** and **Chat turns**, and whether the **System** shows them too. In a browser, **Allow** asks it for permission; the desktop app needs nothing more. Kept on this machine, never per daemon | + +Above the **Daemon** pages, **Daemon** picks which daemon they read and write: the one on screen +until another is picked. It goes into the address as `?daemon=`, so a daemon's page can be +kept and opened again. Picking one changes nothing else on screen. + +**Batch apply**, on **General**, **Fallback**, **Runtimes** and **Backup**, copies what the page +holds to other daemons at once. Tick the daemons, or **All**, then **Apply**. Each daemon then +says **Applied** or why it refused. **General** sends the form as it stands, saved or not. +**Backup** sends every target and default, in place of the daemon's own. A target signed into its URL +is refused there, since the URL is never shown whole. **Fallback** and +**Runtimes** send each step or runtime, written over the one of the same name there, or added. +Nothing a daemon already has is taken away. **Accounts** cannot be copied, since their secrets +are never shown (**Secrets stay put**). **Workspace** and **Usage** are of one daemon's +directories, and have no **Batch apply**. An account is never shown by its values: a page lists the names of the variables it sets, and nothing else. A way in that runs a CLI's own sign-in, such as `login`, needs a terminal: it is @@ -346,10 +372,112 @@ Start `hmz web` in a project, and open **New epic**: - The terminal's transcript shows the task you typed in the browser, marked `· by browser`. - The sidebar lists the epic, `stopped`; **Search epics** shows what it spent. +## Daemons {#daemons} + +Each machine whose epics the page reaches is a **daemon**: the [daemon](/features/daemon) there +holds its runs. **Local** is this machine, always there and never removed. Every other is an ssh +host, reached the way `ssh` reaches it, with your keys and your ssh config. + +The page shows one daemon at a time: the sidebar's folders and epics, a new epic, search, +flows and the Daemon pages of Settings are all that daemon's. To see another's, switch to it. +The daemon is part of every address, `#/@/…`, so a tab can stay on one while another tab +shows a second. + +### The daemon bar {#daemon-bar} + +The foot of the sidebar names the daemon showing, its host, a dot for whether it is reached, and +how many epics are going on it. Click it for its menu: + +- every daemon, with whether it is reached; pick one to switch to it. An ssh host is connected + to when you pick it. +- **Connect** on one that failed, to try it again. +- **Manage**, which opens **Settings → Daemons**. +- **Update**, **Import**, **Settings**, **Usage** and **Download**, as before. + +A daemon is in one of these states: + +| State | What it means | +| --- | --- | +| `idle` | Not connected to yet. | +| `connecting` | `ssh` is reaching the host and starting `hmz web` there. | +| `installing` | The host has no `hmz`, or another version, and it is being installed. See [What happens on connect](#on-connect). | +| `up` | Reached: its epics are on the page. | +| `failed` | It could not be reached; the menu says why, and **Connect** tries again. | + +### Adding one {#adding-a-daemon} + +**Settings → Daemons** lists Local and every ssh host, each with its state. **Add** opens a form, +the same as an [ssh runtime's](/user/settings#an-ssh-host): the **name** the page calls it, then +the host, user, port, key, proxy jump and options, a part at a time. A name is letters, digits, +`.`, `_` and `-`; `local` is Local's and cannot be taken. + +**Import** reads your `~/.ssh/config` and lists every host in it not yet added, with a switch +each: switch on the ones you want, and each is added under its `Host`, resolved through your +config every time, so editing the config edits the daemon. Nothing here writes to the config. + +Edit a daemon, connect to it to check it, or remove it, which asks first. Removing one forgets +how to reach it, and nothing on the host: its epics, and its `hmz`, stay there. + +The daemons are kept in this machine's `~/.hmz/settings.yaml`, under `daemons`. + +### What happens on connect {#on-connect} + +Connecting runs `hmz web --no-open --attached --port 0` on the host over `ssh`, forwards a free +port here to the one it printed with `ssh -N -L`, and passes the page's calls for that daemon +through Local's `hmz web` to it. Nothing on the host listens beyond its own loopback, and your +browser only ever talks to Local. + +- **It brings the host the same humanize.** Before it starts `hmz web` there, it asks the host's + `hmz --version`, through a login shell with `~/.local/bin` on its `PATH`. Where there is none, + or one of another version, it runs `uv tool install --force --python '>=3.12' 'hmz=='` + on the host, at this machine's version, installing uv there first where the host has none. + The daemon says `installing` meanwhile. +- **It never replaces an `hmz` holding running epics.** Where the host's daemon has epics + running, or it cannot tell, the `hmz` there is left as it is, and the daemon says why. +- **It keeps reaching it.** A connection that drops is tried again, waiting longer each time. +- **The epics outlive it.** Stopping `hmz web` here closes every ssh connection, and the + `hmz web` on each host stops with it; the epics there keep going, held by that host's + daemon. See [Leaving it running](/user/leaving). + +## Notifications {#notifications} + +The page tells you when something happens that you may want to look at, on **every** daemon, +not only the one showing: + +| Kind | When | +| --- | --- | +| **done** | An epic ended, done. | +| **failed** | An epic failed, crashed, ran out of its budget, or was refused. | +| **stopped** | An epic was stopped. | +| **asked** | A flow put a question to a person: the outworlder. See [Questions](/user/questions). | +| **listening** | A `chat` finished a turn and waits for your next line. | + +Each says which daemon, which directory and which epic it came from, and what was asked or how +it ended. + +- **The bell**, at the top of the sidebar, counts the ones you have not read. Click it for the + **menu**: the latest notifications of every daemon, newest first, each labelled with its + daemon. Click one to open its epic, switching to its daemon; mark them all read, or clear + them. The last 200 or so are kept. +- **Toasts.** A new notification also shows as a card in the corner of the page for a few + seconds. +- **System notifications.** While the page is hidden or not focused, a notification is also + one of your system's: the browser's, or, in [the macOS app](/user/desktop#notifications), a + macOS one. Clicking it brings the page back on what it is about. + +A daemon is heard from while it is reached: Local always, an ssh host once it is `up`. + +**Settings → Notifications** switches each kind on or off, and system notifications as a whole. +A browser shows system notifications only once you allow it: press the button on that page, +which asks the browser. A kind switched off is not shown, kept or counted. These are App +settings: they are kept in this machine's `~/.hmz/settings.yaml`, under `notifications`, and hold +for every daemon, never one daemon apart. + ## From another machine {#over-ssh} `hmz web` only listens on the machine it runs on. To use it from your laptop while `hmz` runs on -a server, forward a port over ssh: +a server, the simplest way is to add the server as a [daemon](#daemons) of the page on your +laptop. To serve the page from the server itself instead, forward a port over ssh: ```sh ssh -L 8765:127.0.0.1:8765 you@devbox @@ -423,9 +551,29 @@ says. ### The sidebar says why the host let it go The host holding the runs went: it was closed, or it let this window go. The page reaches the -runs again by itself a moment later, starting a host where none is, and the foot of the sidebar +runs again by itself a moment later, starting a host where none is, and the daemon bar says `idle` or how many are `live` again once it has. +### A daemon says `failed` + +`ssh` could not reach the host, or `hmz web` could not start there; the daemon bar's menu, and +**Settings → Daemons**, say what it said. Check that `ssh ` works from a terminal here +without asking for anything -- a password or a host key to accept cannot be answered from the +page -- then press **Connect**. + +### A daemon will not update its `hmz` + +The host's daemon has epics running, or the page could not tell whether it has, so its `hmz` is +left as it is. Once they have ended, press **Connect** again; or update it on the host yourself, +`uv tool install --force 'hmz=='`. + +### No system notifications + +They come only while the page is hidden or not focused, and in a browser only once it is +allowed: press the button in **Settings → Notifications**, and check that the browser's site +settings, and your system's, let it notify. Check too that **Settings → Notifications** has the +kind, and system notifications, switched on. + ### A way in is marked `(at a terminal)` It runs the CLI's own sign-in, which needs a terminal. Make that account from `/settings` in @@ -435,4 +583,5 @@ It runs the CLI's own sign-in, which needs a terminal. Make that account from `/ - [Watching a run (the monitor)](/user/monitor): the same run, drawn in the terminal. - [Leaving it running](/user/leaving): how a run outlives every window onto it. +- [The macOS app](/user/desktop): the same page in a window of its own. - [Web reference](/reference/web): the command line, every route the page uses, and the stream. diff --git a/specs/cli.md b/specs/cli.md index a2a47a3b..825f5fc3 100644 --- a/specs/cli.md +++ b/specs/cli.md @@ -113,7 +113,8 @@ def tools(argv: list[str]) -> int: ... a browser on it unless told `--no-open`, and MUST exit zero once interrupted and one, saying why, where the port cannot be had. A terminate or a hangup MUST stop it as an interrupt does, and so MUST its standard input closing where it is told `--attached`: whatever started it - takes it down by going, however it went. + takes it down by going, however it went -- another machine's `hmz web` reaching this one's + daemon over ssh included. - `hmz update` MUST move this install to the newest release `coganchor.releases` fetches, with whatever installed it -- `uv tool upgrade` for a uv tool, `pipx upgrade` for a pipx one, and otherwise pip, or uv's pip where there is no pip, asked for the extras whose packages are all diff --git a/specs/daemon.md b/specs/daemon.md index 75d828ce..1eb51dab 100644 --- a/specs/daemon.md +++ b/specs/daemon.md @@ -17,7 +17,7 @@ class Daemon: protocol: int = 0 # the frontends' protocol a host speaks; 0 for an older humanize's alive: bool # property def link(self, name: str = "", kind: str = "sdk", *, replay: bool = True) -> Link: ... - def status(self) -> dict[str, Any]: ... + def status(self) -> dict[str, Any]: ... # its host's, as `Host.status` says it def detach(self) -> int: ... def stop(self, *, seconds: float = 20.0) -> bool: ... def kill(self, *, seconds: float = 20.0) -> bool: ... diff --git a/specs/desktop.md b/specs/desktop.md index 37595ce9..1b120bb0 100644 --- a/specs/desktop.md +++ b/specs/desktop.md @@ -3,8 +3,8 @@ The macOS app in `desktop/`: one window on the page `hmz web` serves, which it starts as its own child and stops as it quits. It is a frame around that page and nothing more: it draws nothing of humanize's but how it is getting there and what went wrong, and is answerable only for -having the `hmz` of its own release installed, starting it, showing its page, leaving nothing -running behind it, and keeping itself up to date. +having the `hmz` of its own release installed, starting it, showing its page and what it +notifies, leaving nothing running behind it, and keeping itself up to date. ## API @@ -39,6 +39,9 @@ invoke('status', {after}) // once it is not starting, and not `after`, the stat invoke('retry') // starts again, where it failed, stopped or found no hmz invoke('keep') // starts the hmz there, where installing its own failed and `keep` was said invoke('update_now') // installs its own now, where it is waiting + +// What `hmz web`'s page asks of it, and nothing else. +invoke('notify', {title, body, address}) // a notification; clicked, the page opens `address` ``` `HUMANIZE_HMZ` is for development and tests: it names the `hmz` to run, nothing else is looked @@ -123,8 +126,12 @@ either: it runs the first `hmz` it finds. Reload, Actual Size, Zoom In, Zoom Out and Full Screen; and the documentation under Help. It MUST NOT take a shortcut the page binds: Cmd+K, Cmd+Shift+O and Cmd+. are the page's. - MUST NOT put a script of its own into `hmz web`'s page but what says whether the window is - full screen, nor grant it any of the app's commands, nor anything but to drag and zoom the - window. + full screen, nor grant it any of the app's commands but `notify`, nor anything but that and to + drag and zoom the window. +- MUST show what `notify` is handed as a notification of macOS's own, its title and body as + text, asking macOS for leave the first time; and a click on one MUST bring the window forward + and open its address on `hmz web`'s page, only where it is a `#` address of that page's own + origin. ### Leaving diff --git a/specs/runtime/doing.md b/specs/runtime/doing.md index 9b3370a0..0a6c6347 100644 --- a/specs/runtime/doing.md +++ b/specs/runtime/doing.md @@ -76,7 +76,7 @@ class Host: *, replay: bool = True) -> str: ... # the frontend's client id def detach(self, client: str) -> None: ... def asked(self, client: str, said: dict[str, Any]) -> dict[str, Any]: ... - def status(self) -> dict[str, Any]: ... + def status(self) -> dict[str, Any]: ... # beside what runs: pending, ended, epic, task def away_for(self, role: str) -> bool: ... def printed(self, text: str) -> None: ... attached: int; idle: bool; closed: bool; epic: Path | None # properties @@ -374,6 +374,10 @@ class Fallbacks: that stops or ends with any waiting, MUST say so. Claims MUST NOT gate what is said to agents. - A run MUST be started with the backup targets its frontend chose, or the defaults where it chose none, and how it was started MUST say which. +- `status` MUST say, beside what is running, every question waiting on a person -- + `{question, role, text, mode}`, `mode` being `ask`, or `listen` for a chat waiting on its next + turn -- how the last run ended, `{how, why, epic, at}` or null, and the epic and task of the + run going or the last, so that a frontend can tell what happened without attaching. - One run MUST be going at a time, and any frontend MAY start, stop or force one; forcing MUST close every conversation of a stopping run under whatever turn is open, and say how many. - An aside MUST be read-only, carry no skills, say nothing to the run, answer only the frontend diff --git a/specs/web.md b/specs/web.md index b186dc59..5cb1bf91 100644 --- a/specs/web.md +++ b/specs/web.md @@ -3,8 +3,9 @@ The web interface `hmz web` serves: every workspace's runs on this machine, drawn in a browser on it. Where the terminal interface is about the directory it is opened in, this is about the machine, as the daemon is: one more frontend of the runs each workspace's host holds, beside -every terminal interface opened on them. It runs flows and reads them back, defines no flows, -drives no agents and keeps no store of its own. +every terminal interface opened on them -- and, through the `hmz web` it starts on each over ssh, +of other machines' daemons. It runs flows and reads them back, defines no flows, drives no agents +and keeps no store of its own. ## API @@ -23,6 +24,49 @@ def serve( class Site(ThreadingHTTPServer): port: int # property address: str # property: http://127.0.0.1:/?key= + notices: Notices + remotes: Remotes +# notices.py -- what this machine's runs did that a person would want told +class Notices: + def since(self, after: int = 0) -> list[dict[str, Any]]: ... # each numbered after `after` + def heard(self, callback: Callable[[dict[str, Any]], None]) -> None: ... # each as it comes +# remote.py -- the daemons of other machines, each reached over ssh +class Remotes: + def state(self, name: str) -> dict[str, Any]: ... # {state, detail, version} + def reached(self, name: str) -> tuple[int, str]: ... # its port forwarded here, and its key + def connect(self, name: str) -> None: ... + def close(self) -> None: ... +``` + +```text +GET /api/notices?after= {notices, last}, of this machine's runs alone +GET /api/notices/stream `notice` events, resumed from Last-Event-ID + a notice {id, kind, workspace, epic, flow, task, role, text, at} + its kind done | failed | stopped | asked | listening +GET /api/daemons Local, then each SSH daemon, with how it stands +POST /api/daemons one added or changed +POST /api/daemons/remove one removed +POST /api/daemons/import ssh config's hosts not yet added, those named +GET /api/daemons/hosts ssh config's hosts not yet added +POST /api/daemons//connect reached again +GET /api/daemons//state idle | connecting | installing | up | failed + /d//api/... that daemon's /api/..., proxied; /d/local/api/ is /api/ +GET /api/notifications {items, unread}: every daemon's notices, each with + its daemon and whether it is read +GET /api/notifications/stream `notification` events +POST /api/notifications/read {ids} or {all} +POST /api/notifications/clear +GET /api/notifications/settings {kinds: {done, failed, stopped, asked, listening}, system} +POST /api/notifications/settings +``` + +```yaml +# settings.yaml, Local's alone +daemons: # each SSH daemon; Local is always there and never written + - {name, host, user, port, identity_file, proxy_jump, options: [[key, value]], alias, config} +notifications: # every kind, and the system, on unless turned off + kinds: {done: true, failed: true, stopped: true, asked: true, listening: true} + system: true ``` `listening` attaches to the runs and listens without answering; `Site.serve_forever` answers @@ -207,5 +251,59 @@ until `Site.shutdown`. `serve` is both, until it is interrupted. and the desktop app alike. - MUST draw its chrome around the window controls of the desktop app when it is shown in it, and nothing else differently. -- MUST make every view an address that can be kept and opened again, and MUST say, rather than - hide, a refusal where it happened. +- MUST make every view an address that can be kept and opened again, the daemon it is of + included, and MUST say, rather than hide, a refusal where it happened. + +### Daemons + +- MUST keep the daemons in Local's settings: `Local`, this machine's, always there and never + removed or renamed, and every other an SSH host -- set up a part at a time as a runtime's ssh + host is, or imported from the ssh config, those already added skipped -- named by letters, + digits, `.`, `_` and `-`, `local` being Local's alone. +- MUST reach an SSH daemon by running `hmz web --no-open --attached --port 0` there over ssh, as + its entry says, and forwarding a free port of this machine's loopback to the address it prints, + holding ssh's input open so that it goes as this does; MUST try again, waiting longer each + time, where it drops; and MUST stop everything it started there as it goes. +- MUST find the `hmz` there as a login shell finds it, `~/.local/bin` included, and where none is + or it is not this one's version, install this one's with `uv tool install --force --python + '>=3.12' 'hmz=='`, getting uv there with astral's installer first where it has none, + saying `Installing` while it does. It MUST NOT replace one while that machine's daemon holds + running epics, nor where it cannot tell, saying why and reaching nothing; and a development + build, `0.0.0`, MUST install nothing. +- MUST hand a page an SSH daemon's `/api/` under `/d//api/` of its own origin -- + server-sent events and uploads streamed through, and the key and cookie that daemon's `hmz web` + takes put in here, never handed to the page -- and nothing else of it; `/d/local/api/` MUST be + `/api/`. One not reached MUST be refused as unavailable, saying how it stands. +- MUST draw the sidebar's foot as the daemon bar: the daemon shown, its host, how it stands and + how many epics it runs. A click on it MUST open the picker -- every daemon and how it stands, + one picked switched to, `Connect` on one that failed, `Manage` for the daemons in Settings -- + with the machine's own choices below it. +- MUST show the daemon picked alone -- its workspaces, epics, flows, settings and everything + else but the notifications -- carrying it in every address as `#/@/...`, an address + without one being Local's, and MUST draw everything again of the daemon switched to. +- MUST group Settings as `App`, what is this page's and Local's alone -- themes, daemons and + notifications -- and `Daemon`, every page a daemon keeps -- general, accounts, fallback, + runtimes, backup, workspace and usage -- read and written of the daemon picked on them, the + one shown at first. Each Daemon page MUST offer `Batch apply`, which writes what that page + saves to every daemon picked and says how each went; MUST NOT carry what only a secret sets, + and MUST say so where it is left out. + +### Notifications + +- MUST tell, of this machine's runs, every epic that ends -- `done`, `stopped`, or `failed` + where it failed, crashed, ran out of budget or was refused -- every question a flow asks a + person, `asked`, and every chat waiting on its next turn, `listening`: each a notice, numbered + in order and kept to a bound, served after a number and as server-sent events resumed from the + last heard, `hmz web` reached over ssh included. It MUST work them out of each host's status as + the daemon answers it, never by attaching, so that no host is kept for them. +- MUST gather, on Local alone, the notices of every daemon reached into one list of + notifications, each naming its daemon and whether it is read, the newest few hundred kept, and + MUST keep out of it, before it is kept or sent, every kind turned off. +- MUST offer them, whichever daemon is shown, behind a bell with how many are unread: each + opening its epic on its daemon, marked read one or all, and cleared; and each new one as a card + that goes after a while. +- MUST tell the system of a new one where the page is hidden or not focused, unless that is + turned off: in the desktop app by its `notify`, and in a browser by its notifications, leave + for which is asked only by a click in Settings. +- MUST keep which kinds are told and whether the system is in Local's settings alone, never a + daemon's. diff --git a/src/hmz/runtime/doing/hosting.py b/src/hmz/runtime/doing/hosting.py index 8b112654..a8fdda1e 100644 --- a/src/hmz/runtime/doing/hosting.py +++ b/src/hmz/runtime/doing/hosting.py @@ -182,6 +182,12 @@ def _json(model: Any) -> Any: return json.loads(model.model_dump_json()) +def _epic_of(shown: _Run | None) -> str: + """The name of the epic a run is written into, or "" before it has one.""" + epic = shown.run.epic if shown is not None else None + return epic.name if epic is not None else "" + + def _spoken(given: Mapping[str, Any]) -> dict[str, str]: """What each role was given, as the words `-a` or `-e` would spell it.""" return { @@ -347,6 +353,8 @@ def __init__(self, hmz: Hmz) -> None: self._current: _Run | None = None self._stopping: _Run | None = None self._last: _Run | None = None + #: How the last run to end here ended, for whoever asks from outside the runs. + self._ended: dict[str, Any] | None = None self._runs = 0 self._starting = False self._questions: list[_Question] = [] @@ -569,14 +577,25 @@ def status(self) -> dict[str, Any]: Returns: How many frontends are attached and who, the state and number of the run, the - flow, what it may spend and has spent -- both None with nothing running -- and the - flows running, as refs and as calls. + flow, its epic and task, what it may spend and has spent -- both None with nothing + running -- the questions it waits on, how the last run to end ended, and the flows + running, as refs and as calls. """ with self._lock: running = self._current shown = running or self._stopping or self._last clients = self._clients_of() state = self._state() + pending = [ + { + "question": one.id, + "role": one.role, + "text": one.text, + "mode": one.mode, + } + for one in self._questions + ] + ended = dict(self._ended) if self._ended is not None else None calls = self._calls(now=time.monotonic()) return { "attached": len(clients), @@ -584,6 +603,10 @@ def status(self) -> dict[str, Any]: "state": state, "run": shown.number if shown is not None else 0, "flow": shown.run.flow if shown is not None else "", + "epic": _epic_of(shown), + "task": shown.started.get("task", "") if shown is not None else "", + "pending": pending, + "ended": ended, "budget": _json(running.run.budget) if running is not None else None, "usage": _json(running.run.usage) if running is not None else None, "flows": [one["ref"] for one in calls], @@ -1030,6 +1053,15 @@ def _drives(self, current: _Run) -> None: # arrive is the one it ended for. if not self._frontends and not current.stopped_by: self._keeps = True + self._ended = { + "how": how, + "why": why, + "epic": _epic_of(current), + "at": time.time(), + "run": current.number, + "flow": current.run.flow, + "task": current.started.get("task", ""), + } self._record( { "type": "ended", diff --git a/src/hmz/web/__init__.py b/src/hmz/web/__init__.py index 5c6d7197..1444f609 100644 --- a/src/hmz/web/__init__.py +++ b/src/hmz/web/__init__.py @@ -59,7 +59,10 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]: from hmz.coganchor import releases + from .notices import Notices + from .notifications import Notifications from .places import Places + from .remote import Remotes from .runs import Runs from .site import Site from .verses import fetches @@ -67,14 +70,25 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]: with contextlib.ExitStack() as held_open: places = Places(Path.cwd(), apart=apart) held_open.callback(places.close) - site = Site(port, places=places, runs=Runs(places)) + notices = Notices(places.statuses) + site = Site(port, places=places, runs=Runs(places), notices=notices) # What each index lists now, fetched once in the background as the interface starts. fetches(places.hmz) # And which release of humanize is the newest, where what is kept has got old. releases.refresh() held_open.callback(site.server_close) + # Every daemon on another machine let go of as the block ends, and what reached it. + site.remotes = Remotes() + held_open.callback(site.remotes.close) + # Every daemon's notices, as this machine's interface tells them. + site.notifications = Notifications(site) + held_open.callback(site.notifications.close) # Set first as the block ends, which is what ends every stream being sent. held_open.callback(site.going.set) + # And before it, what ends the notices' streams at once rather than once they wake. + held_open.callback(notices.close) + # What stands as it starts to answer is where the notices start from. + notices.start() yield site diff --git a/src/hmz/web/daemons.py b/src/hmz/web/daemons.py new file mode 100644 index 00000000..5d867160 --- /dev/null +++ b/src/hmz/web/daemons.py @@ -0,0 +1,294 @@ +"""The daemons a page may switch between: this machine's, and each ssh host written down. + +`Local` is always there and never written down. Every other daemon is an ssh host, kept in +humanize's one settings file under `daemons` with what `ssh` is told to reach it -- the same +fields an ssh runtime has, checked as one is, but for its workdir and fallback -- typed in a +part at a time or brought in from the ssh config. How each stands is the remote reach's to +say, where this site has one; without one, Local is up and every other daemon is idle. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any, cast + +from .routing import Refusal, routes + +if TYPE_CHECKING: + from collections.abc import Callable + + from .routing import Asked + +__all__ = ["LOCAL", "ROUTES", "found", "listed", "saved"] + +#: The daemon of this machine, which is always there and never written down. +LOCAL = "local" + +#: The key of the settings file the daemons are kept under. +_KEY = "daemons" + +#: What a daemon's name is spelled with: never starting with a dot, so never `.` or `..`. +_NAMED = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") + +#: What a daemon holds beside its name, as `ssh` is told it. +_FIELDS = ( + "host", + "user", + "port", + "identity_file", + "proxy_jump", + "options", + "alias", + "config", +) + +#: What a write of one takes. +_TAKES = frozenset({"name", "fields", "replace"}) + + +def _checked(name: str, fields: dict[str, Any]) -> dict[str, Any]: + """One daemon as it is written down, checked as an ssh runtime is. + + Args: + name: What it is called. + fields: The rest of it, `options` as `[[KEYWORD, VALUE], ...]` or by keyword. + + Returns: + It, `options` as pairs. + + Raises: + ValueError: If it cannot be one. + """ + from hmz.coganchor.machines.store import SSH, SSHRuntime, new + + if not _NAMED.fullmatch(name) or name.lower() == LOCAL: + raise ValueError( + f"A daemon cannot be called {name!r}: say a letter or digit, then letters, " + f"digits, dots, dashes and underscores, and not {LOCAL}." + ) + if extra := sorted(set(fields) - set(_FIELDS)): + raise ValueError(f"A daemon takes no {', '.join(extra)}.") + given = dict(fields) + options: object = given.get("options", []) + if isinstance(options, list): + pairs = cast("list[object]", options) + if not all( + isinstance(one, list) and len(cast("list[object]", one)) == 2 # noqa: PLR2004 + for one in pairs + ): + raise ValueError("Say options as pairs of a keyword and its value.") + given["options"] = { + str(key): str(value) for key, value in cast("list[list[object]]", pairs) + } + one = cast("SSHRuntime", new(SSH, name, **given)) + return { + "name": one.name, + **{field: getattr(one, field) for field in _FIELDS if field != "options"}, + "options": [[key, value] for key, value in one.options.items()], + } + + +def _valid(held: dict[str, Any]) -> list[dict[str, Any]]: + """Every ssh daemon a reading of the settings file holds, in order, each checked. + + One that cannot be one -- written wrong by hand, or under a name taken before it -- is + left out, and so is gone the next time the daemons are written. + """ + there: object = held.get(_KEY) + kept: list[dict[str, Any]] = [] + for one in cast("list[object]", there) if isinstance(there, list) else []: + fields = dict(cast("dict[str, Any]", one)) if isinstance(one, dict) else {} + name = fields.pop("name", None) + if not isinstance(name, str): + continue + try: + entry = _checked(name, fields) + except ValueError: + continue + if all(entry["name"] != each["name"] for each in kept): + kept.append(entry) + return kept + + +def saved() -> list[dict[str, Any]]: + """Every ssh daemon written down, in order; one that cannot be one is left out.""" + from hmz.coganchor import settings + + return _valid(settings.read()) + + +def found(name: str) -> dict[str, Any] | None: + """The ssh daemon written down under a name, or None where there is none.""" + return next((one for one in saved() if one["name"] == name), None) + + +def listed(site: Any) -> dict[str, Any]: + """Local, then every ssh daemon, each with how it stands.""" + from hmz.coganchor import __version__ + + remotes = getattr(site, "remotes", None) + local: dict[str, Any] = { + "name": LOCAL, + **dict.fromkeys(_FIELDS, ""), + "port": 0, + "options": [], + "label": "Local", + "state": "up", + "detail": "", + "version": __version__, + } + daemons = [local] + for one in saved(): + stands = ( + dict(remotes.state(one["name"])) + if remotes is not None + else {"state": "idle", "detail": "", "version": ""} + ) + daemons.append(one | {"label": one["name"]} | stands) + return {"daemons": daemons} + + +def _changes(change: Callable[[list[dict[str, Any]]], None]) -> None: + """Makes one change to the daemons written down, and to nothing else in the file. + + Raises: + Refusal: If the file cannot be written. + """ + from hmz.coganchor import settings + + def changing(held: dict[str, Any]) -> None: + daemons = _valid(held) + change(daemons) + held[_KEY] = daemons + + try: + settings.changes(changing) + except OSError as why: + raise Refusal(f"The daemons could not be written: {why}", 500) from why + + +def _write(asked: Asked) -> dict[str, Any]: + if extra := sorted(set(asked.body) - _TAKES): + raise Refusal(f"A daemon is written with no {', '.join(extra)}.") + name = asked.text("name") + replacing = asked.body.get("replace", False) + if not isinstance(replacing, bool): + raise Refusal("Say replace as true or false.") + try: + entry = _checked(name, asked.named("fields")) + except ValueError as why: + raise Refusal(str(why)) from why + + def change(daemons: list[dict[str, Any]]) -> None: + at = next((i for i, one in enumerate(daemons) if one.get("name") == name), -1) + if at < 0: + daemons.append(entry) + elif replacing: + daemons[at] = entry + else: + raise Refusal(f"There is a daemon called {name} already.", 409) + + _changes(change) + return listed(asked.site) + + +def _remove(asked: Asked) -> dict[str, Any]: + if extra := sorted(set(asked.body) - {"name"}): + raise Refusal(f"A daemon is removed with no {', '.join(extra)}.") + name = asked.text("name") + if name.lower() == LOCAL: + raise Refusal("Local is always there, and cannot be removed.") + + def change(daemons: list[dict[str, Any]]) -> None: + kept = [one for one in daemons if one.get("name") != name] + if len(kept) == len(daemons): + raise Refusal(f"There is no daemon called {name}.", 404) + daemons[:] = kept + + _changes(change) + return listed(asked.site) + + +def _named(alias: str) -> str: + """What a host of the ssh config is called as a daemon: anything no name holds a dash.""" + return re.sub(r"[^A-Za-z0-9._-]", "-", alias).lstrip("._-") + + +def _offered(aliases: list[str]) -> dict[str, dict[str, Any]]: + """Each alias that can be brought in as a daemon not written down yet, as it would be. + + Of two aliases that would be called the same, the first is offered. + """ + daemons = saved() + names = {one["name"] for one in daemons} + taken = {one["alias"] for one in daemons} + offered: dict[str, dict[str, Any]] = {} + for alias in aliases: + if alias in taken: + continue + try: + one = _checked(_named(alias), {"alias": alias}) + except ValueError: + continue + if one["name"] not in names: + names.add(one["name"]) + offered[alias] = one + return offered + + +def _hosts(_asked: Asked) -> dict[str, Any]: + import dataclasses + + from hmz.coganchor.machines import sshconfig + + try: + return { + "hosts": [ + dataclasses.asdict(sshconfig.resolve(alias)) | {"name": one["name"]} + for alias, one in _offered(sshconfig.aliases()).items() + ] + } + except OSError as why: + raise Refusal(f"The ssh config could not be read: {why}", 503) from why + + +def _imports(asked: Asked) -> dict[str, Any]: + from hmz.coganchor.machines import sshconfig + + if extra := sorted(set(asked.body) - {"aliases"}): + raise Refusal(f"An import takes no {', '.join(extra)}.") + wanted = asked.texts("aliases") + if not wanted: + raise Refusal("Say aliases as the hosts to bring in, by their Host.") + there = sshconfig.aliases() + if missing := [one for one in wanted if one not in there]: + raise Refusal(f"Your ssh config has no host {', '.join(missing)}.", 404) + for alias in wanted: + try: + _checked(_named(alias), {"alias": alias}) + except ValueError as why: + raise Refusal(f"{alias} cannot be a daemon: {why}") from why + added: list[str] = [] + + def change(daemons: list[dict[str, Any]]) -> None: + names = {one["name"] for one in daemons} + taken = {one["alias"] for one in daemons} + for alias in dict.fromkeys(wanted): + one = _checked(_named(alias), {"alias": alias}) + if alias not in taken and one["name"] not in names: + daemons.append(one) + names.add(one["name"]) + added.append(one["name"]) + + _changes(change) + return listed(asked.site) | {"added": added} + + +#: What a page reads of the daemons, and does to them. +ROUTES = routes( + ("GET", "/api/daemons", lambda asked: listed(asked.site)), + ("POST", "/api/daemons", _write), + ("POST", "/api/daemons/remove", _remove), + ("GET", "/api/daemons/hosts", _hosts), + ("POST", "/api/daemons/import", _imports), +) diff --git a/src/hmz/web/notices.py b/src/hmz/web/notices.py new file mode 100644 index 00000000..b8b936d3 --- /dev/null +++ b/src/hmz/web/notices.py @@ -0,0 +1,273 @@ +"""What happened to the runs of this machine worth telling somebody about, as notices. + +An epic ending, a question for a person, a chat waiting for its next turn: each is a notice, +worked out of how every workspace's runs stand -- asked of each host every couple of seconds, +as `Places` asks what runs, and never by attaching to it, which would keep a host that would +otherwise go. What stood when this started is where it starts from rather than news. The last +few are kept, each numbered after the one before and after any this machine numbered before it +was started again, so that a page -- or another machine's interface -- carries on from the last +one it heard. +""" + +from __future__ import annotations + +import collections +import contextlib +import threading +import time +from typing import TYPE_CHECKING, Any + +from .routing import Refusal, Stream, routes + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from .routing import Asked + +__all__ = ["ROUTES", "Notices"] + +#: How often how the runs stand is asked, in seconds. +_EVERY = 2.0 + +#: How many notices are kept. +_KEPT = 200 + +#: How long a stream says nothing before it says it is still there, in seconds. +_QUIET = 15.0 + +#: The kind of notice each way an epic can end is. +_ENDED = { + "done": "done", + "stopped": "stopped", + "failed": "failed", + "crashed": "failed", + "budget": "failed", + "refused": "failed", +} + +#: The kind of notice a question waited on is, by its mode. +_ASKED = {"ask": "asked", "listen": "listening"} + + +class Notices: + """The notices of this machine's runs, worked out of how they stand as that changes. + + Args: + statuses: How the runs of each workspace stand, by its path, as a host's `status` + says: asked once a look, and never of a host it would keep. + every: How often to look once started, in seconds. + """ + + def __init__( + self, + statuses: Callable[[], dict[str, dict[str, Any]]], + *, + every: float = _EVERY, + ) -> None: + self._statuses = statuses + self._every = every + self._changed = threading.Condition() + self._kept: collections.deque[dict[str, Any]] = collections.deque(maxlen=_KEPT) + self._last = 0 + self._listeners: list[Callable[[dict[str, Any]], None]] = [] + #: How each workspace last stood, where its host said: what is news is against this. + self._stood: dict[str, dict[str, Any]] | None = None + #: When it first looked: an epic that ended before then is no news, wherever it is. + self._began = 0.0 + self._closed = threading.Event() + + @property + def last(self) -> int: + """The id of the newest notice, or 0 where there is none.""" + with self._changed: + return self._last + + def start(self) -> None: + """Looks every so often, on a thread of its own, until closed.""" + threading.Thread(target=self._looking, daemon=True, name="hmz-notices").start() + + def look(self) -> list[dict[str, Any]]: + """Asks how the runs stand, once, and tells what is new since the last look. + + The first look is where it starts from: nothing that stood then is news. + + Returns: + The notices that look made, oldest first. + """ + statuses = self._statuses() + made: list[dict[str, Any]] = [] + with self._changed: + first = self._stood is None + if first: + self._began = time.time() + stood = self._stood if self._stood is not None else {} + for workspace, status in statuses.items(): + # A host that would not answer says neither: unknown, rather than nothing. + if "pending" not in status or "ended" not in status: + continue + if not first: + was = stood.get(workspace, {}) + made += _noticed(workspace, was, status, self._began) + stood[workspace] = { + # Which host it was, apart: another one numbers its questions afresh. + "host": (status.get("pid"), status.get("started")), + "ended": status.get("ended"), + "pending": list(status.get("pending") or []), + } + self._stood = stood + for one in made: + self._last = one["id"] = max(self._last + 1, int(time.time() * 1000)) + self._kept.append(one) + if made: + self._changed.notify_all() + listeners = list(self._listeners) + for one in made: + for listener in listeners: + with contextlib.suppress(Exception): + listener(dict(one)) + return made + + def since(self, after: int | str = 0) -> list[dict[str, Any]]: + """The notices kept after one, oldest first. + + Args: + after: The id of the last one heard, or 0 for every one kept. + + Raises: + Refusal: If `after` is not a whole number. + """ + try: + heard = int(after or 0) + except ValueError as why: + raise Refusal("Say after as a whole number.") from why + with self._changed: + return [dict(one) for one in self._kept if one["id"] > heard] + + def heard(self, listener: Callable[[dict[str, Any]], None]) -> Callable[[], None]: + """Tells a listener each notice as it is made, on the thread that looks. + + Args: + listener: Told each notice; what it raises is not this one's. + + Returns: + What stops telling it. + """ + with self._changed: + self._listeners.append(listener) + + def unheard() -> None: + with self._changed, contextlib.suppress(ValueError): + self._listeners.remove(listener) + + return unheard + + def stream(self) -> Stream: + """Every notice after the last one a page heard, then each as it comes. + + A page that heard none hears only those made from now on. + """ + return Stream(self._events) + + def close(self) -> None: + """Stops looking, and ends every stream.""" + self._closed.set() + with self._changed: + self._changed.notify_all() + + def _events( + self, last: str, going: threading.Event + ) -> Iterator[tuple[str, str, Any] | None]: + # Where it starts from is as it is asked for, not as it is first read. + return self._after(int(last) if last.isdigit() else self.last, going) + + def _after( + self, after: int, going: threading.Event + ) -> Iterator[tuple[str, str, Any] | None]: + while not (going.is_set() or self._closed.is_set()): + with self._changed: + fresh = [one for one in self._kept if one["id"] > after] + if not fresh: + self._changed.wait(_QUIET) + fresh = [one for one in self._kept if one["id"] > after] + if not fresh: + yield None + continue + for one in fresh: + after = one["id"] + yield str(after), "notice", dict(one) + + def _looking(self) -> None: + while True: + with contextlib.suppress(Exception): + self.look() + if self._closed.wait(self._every): + return + + +def _noticed( + workspace: str, was: dict[str, Any], now: dict[str, Any], began: float +) -> list[dict[str, Any]]: + """What is news in how one workspace's runs stand now against how they stood. + + Args: + workspace: Whose runs. + was: How they last stood, or nothing where they were not seen before. + now: How they stand now. + began: When the first look was: an epic that ended before is no news. + """ + made: list[dict[str, Any]] = [] + ended = now.get("ended") + if ended and ended != was.get("ended") and float(ended.get("at") or began) >= began: + why = str(ended.get("why") or "").strip() + made.append( + { + "kind": _ENDED.get(str(ended.get("how")), "failed"), + "workspace": workspace, + "epic": ended.get("epic") or "", + "flow": ended.get("flow") or now.get("flow") or "", + "task": ended.get("task") or now.get("task") or "", + "role": "", + # Why in a line: of a crash, the last of its traceback. + "text": why.splitlines()[-1] if why else "", + "at": ended.get("at") or time.time(), + } + ) + same = was.get("host") == (now.get("pid"), now.get("started")) + stood: list[dict[str, Any]] = (was.get("pending") or []) if same else [] + pending: list[dict[str, Any]] = now.get("pending") or [] + asked = {one.get("question") for one in stood} + for one in pending: + kind = _ASKED.get(one.get("mode", "")) + if kind is None or one.get("question") in asked: + continue + made.append( + { + "kind": kind, + "workspace": workspace, + "epic": now.get("epic") or "", + "flow": now.get("flow") or "", + "task": now.get("task") or "", + "role": one.get("role") or "", + "text": one.get("text") or "", + "at": time.time(), + } + ) + return made + + +def _notices(asked: Asked) -> dict[str, Any]: + notices: Notices = asked.site.notices + found = notices.since(asked.query.get("after", "0")) + return {"notices": found, "last": notices.last} + + +def _stream(asked: Asked) -> Stream: + notices: Notices = asked.site.notices + return notices.stream() + + +#: What a page reads of the notices. +ROUTES = routes( + ("GET", "/api/notices", _notices), + ("GET", "/api/notices/stream", _stream), +) diff --git a/src/hmz/web/notifications.py b/src/hmz/web/notifications.py new file mode 100644 index 00000000..b30391de --- /dev/null +++ b/src/hmz/web/notifications.py @@ -0,0 +1,396 @@ +"""Notifications: what every daemon's notices come to, on this machine's interface alone. + +Each `hmz web` serves its own machine's notices -- an epic ended, a flow asking a person, a +chat waiting for its next turn. The interface a person has open is told them of every daemon at +once: this machine's as they are heard, and each SSH daemon's, while it is reached, off the +stream its own interface serves. Each is told by which daemon said it, kept only where the kinds +a person wants to be told of say so, and kept a while -- the last few hundred -- with whether it +was read, for every page this interface serves to follow from where it last heard. + +Which kinds are told, and whether the system is asked to show them too, are the app's own +settings, kept under `notifications` in this machine's `settings.yaml` and never a daemon's. +""" + +from __future__ import annotations + +import collections +import contextlib +import http.client +import json +import threading +import time +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, cast + +from .routing import Refusal, Stream, routes + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from .routing import Asked + +__all__ = ["KINDS", "ROUTES", "Notifications", "daemons", "preferences"] + +#: Every kind of notice there is: an epic done, failed or stopped, a person asked, and a chat +#: listening for its next turn. +KINDS = ("done", "failed", "stopped", "asked", "listening") + +#: How many notifications are kept. +_KEPT = 200 + +#: How often the daemons there are, and which are reached, are looked at again. +_LOOKING = 2.0 + +#: How long a stream says nothing before it says it is still there. +_QUIET = 15.0 + +#: How long a daemon's interface may say nothing before its stream is given up on: it says it is +#: still there more often than this. +_SILENT = 60.0 + + +def preferences(held: dict[str, Any] | None = None) -> dict[str, Any]: + """Which notifications are wanted, as `settings.yaml` holds them: every one, where it is silent. + + Args: + held: Everything `settings.yaml` holds; read afresh where None. + + Returns: + `kinds`, whether each kind is told, and `system`, whether the system is asked to show + them too. + """ + if held is None: + from hmz.coganchor import settings + + held = settings.read() + said: object = held.get("notifications") + wanted = cast("dict[str, Any]", said) if isinstance(said, dict) else {} + kinds: object = wanted.get("kinds") + each = cast("dict[str, Any]", kinds) if isinstance(kinds, dict) else {} + return { + "kinds": {kind: each.get(kind) is not False for kind in KINDS}, + "system": wanted.get("system") is not False, + } + + +def daemons(held: dict[str, Any] | None = None) -> list[str]: + """The name of every SSH daemon `settings.yaml` holds, as it holds them. + + Args: + held: Everything `settings.yaml` holds; read afresh where None. + """ + if held is None: + from hmz.coganchor import settings + + held = settings.read() + said: object = held.get("daemons") + entries = cast("list[object]", said) if isinstance(said, list) else [] + named = [ + cast("dict[str, Any]", one).get("name") + for one in entries + if isinstance(one, dict) + ] + return [str(one) for one in named if one and one != "local"] + + +class Notifications: + """Every daemon's notices, as this machine's interface tells them. + + Hears this machine's own as they come, and follows each SSH daemon's while it is reached: + the notices held as `site.notices` and the daemons reached through `site.remotes`, each + looked for again as long as it is missing. + + Args: + site: What holds this machine's notices and the daemons reached, where it has them. + wanted: Which notifications are wanted, asked afresh of every notice. + named: The SSH daemons there are, asked afresh every little while. + """ + + def __init__( + self, + site: Any, + *, + wanted: Callable[[], dict[str, Any]] = preferences, + named: Callable[[], list[str]] = daemons, + ) -> None: + self._site = site + self._wanted = wanted + self._named = named + self._changed = threading.Condition() + self._kept: collections.deque[dict[str, Any]] = collections.deque(maxlen=_KEPT) + self._told = 0 + self._closed = threading.Event() + self._hearing: object = None + #: Each daemon followed, by name: the connection its stream comes over, once there is one. + self._following: dict[str, list[http.client.HTTPConnection]] = {} + #: The last notice heard of each daemon, by name: where its stream is taken up again. + self._last: dict[str, str] = {} + self._hears() + threading.Thread( + target=self._looks, daemon=True, name="hmz-web-notifications" + ).start() + + def told(self, daemon: str, notice: dict[str, Any]) -> dict[str, Any] | None: + """Keeps one daemon's notice as a notification, where its kind is wanted. + + Args: + daemon: Which said it: `local`, or an SSH daemon's name. + notice: The notice, as its daemon serves it. + + Returns: + The notification, or None where its kind is not wanted. + """ + if not self._wanted()["kinds"].get(str(notice.get("kind")), True): + return None + with self._changed: + self._told += 1 + made = notice | {"id": str(self._told), "daemon": daemon, "read": False} + self._kept.append(made) + self._changed.notify_all() + return dict(made) + + def listed(self) -> dict[str, Any]: + """Every notification kept, newest first, and how many are unread.""" + with self._changed: + items = [dict(one) for one in reversed(self._kept)] + return {"items": items, "unread": sum(not one["read"] for one in items)} + + def read(self, ids: list[str] | None = None) -> dict[str, Any]: + """Marks notifications read: those named, or every one where none are. + + Returns: + As `listed`. + """ + with self._changed: + for one in self._kept: + if ids is None or one["id"] in ids: + one["read"] = True + return self.listed() + + def clear(self) -> dict[str, Any]: + """Forgets every notification kept. + + Returns: + As `listed`. + """ + with self._changed: + self._kept.clear() + return self.listed() + + def stream(self) -> Stream: + """Every notification after the one a page last heard, then each as it comes.""" + return Stream(self._events) + + def close(self) -> None: + """Stops following every daemon, and ends every stream.""" + self._closed.set() + with self._changed: + following = [one for held in self._following.values() for one in held] + self._changed.notify_all() + for connection in following: + with contextlib.suppress(OSError): + connection.close() + + def _events( + self, last: str, going: threading.Event + ) -> Iterator[tuple[str, str, Any] | None]: + with self._changed: + told = self._told + # One heard of an interface before this, which counted from the start again, has + # heard none of this one's. Where it starts from is as it is asked for, not as it is + # first read. + heard = int(last) if last.isdigit() else told + return self._after(heard if heard <= told else 0, going) + + def _after( + self, after: int, going: threading.Event + ) -> Iterator[tuple[str, str, Any] | None]: + while not (going.is_set() or self._closed.is_set()): + with self._changed: + fresh = [dict(one) for one in self._kept if int(one["id"]) > after] + if not fresh: + self._changed.wait(_QUIET) + fresh = [dict(one) for one in self._kept if int(one["id"]) > after] + if not fresh: + yield None + continue + for one in fresh: + after = int(one["id"]) + yield one["id"], "notification", one + + def _hears(self) -> None: + """Hears this machine's own notices, once there are any to hear.""" + notices = getattr(self._site, "notices", None) + if notices is None or self._hearing is notices: + return + self._hearing = notices + notices.heard(self._heard) + + def _heard(self, said: dict[str, Any] | list[dict[str, Any]]) -> None: + for notice in said if isinstance(said, list) else [said]: + self.told("local", notice) + + def _looks(self) -> None: + """Every little while: this machine's notices, and every SSH daemon reached, followed.""" + while not self._closed.wait(_LOOKING): + # Whatever went wrong is looked at again in a while. + with contextlib.suppress(Exception): + self._hears() + self._reaches(getattr(self._site, "remotes", None)) + + def _reaches(self, remotes: Any) -> None: + """Follows every SSH daemon reached that is not followed yet.""" + if remotes is None: + return + for name in self._named(): + if name in self._following or remotes.state(name).get("state") != "up": + continue + with self._changed: + self._following[name] = [] + threading.Thread( + target=self._follows, + args=(remotes, name), + daemon=True, + name=f"hmz-web-notices-{name}", + ).start() + + def _follows(self, remotes: Any, name: str) -> None: + """Follows one daemon's notices while it is reached, from where it was last heard.""" + try: + port, key = remotes.reached(name) + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=_SILENT) + with self._changed: + if self._closed.is_set(): + return + self._following[name].append(connection) + cookie = _signs_in(connection, key) + # What it said before it was first reached was said to nobody here, and is not + # told; one started again since it was last heard counts from the start again. + now = str(_asks(connection, cookie, "/api/notices")["last"]) + heard = self._last.get(name) + if heard is None or ( + heard.isdigit() and now.isdigit() and int(heard) > int(now) + ): + self._last[name] = now if heard is None else "0" + connection.request( + "GET", + "/api/notices/stream", + headers={"Cookie": cookie, "Last-Event-ID": self._last[name]}, + ) + answer = connection.getresponse() + if answer.status != HTTPStatus.OK: + return + event, ident, data = "", "", list[str]() + while not self._closed.is_set(): + line = answer.readline() + if not line: + return + said = line.decode().rstrip("\r\n") + if said.startswith("id:"): + ident = said[3:].strip() + elif said.startswith("event:"): + event = said[6:].strip() + elif said.startswith("data:"): + data.append(said[5:].removeprefix(" ")) + elif not said: + if event == "notice" and data: + notice = json.loads("\n".join(data)) + self._last[name] = ident or str(notice.get("id", "")) + self.told(name, notice) + event, ident, data = "", "", list[str]() + except Exception: # noqa: BLE001 -- followed again once it is reached again + time.sleep(_LOOKING) + finally: + with self._changed: + for connection in self._following.pop(name, []): + with contextlib.suppress(OSError): + connection.close() + + +def _signs_in(connection: http.client.HTTPConnection, key: str) -> str: + """Lets this in to a daemon's interface with its key, as a browser is let in. + + Returns: + The cookie it is handed, named for the port that interface listens on there. + + Raises: + OSError: If it would not let this in. + """ + connection.request("GET", f"/?key={key}") + answer = connection.getresponse() + answer.read() + cookie = (answer.getheader("Set-Cookie") or "").partition(";")[0] + if not cookie: + raise OSError(f"the daemon's interface would not let this in: {answer.status}") + return cookie + + +def _asks( + connection: http.client.HTTPConnection, cookie: str, path: str +) -> dict[str, Any]: + """What a daemon's interface answers one read with.""" + connection.request("GET", path, headers={"Cookie": cookie}) + answer = connection.getresponse() + body = answer.read() + if answer.status != HTTPStatus.OK: + raise OSError(f"the daemon's interface answered {answer.status}") + return cast("dict[str, Any]", json.loads(body)) + + +def _held(asked: Asked) -> Notifications: + held = getattr(asked.site, "notifications", None) + if held is None: + raise Refusal("Notifications are not kept here.", 404) + return cast("Notifications", held) + + +def _read(asked: Asked) -> dict[str, Any]: + if asked.body.get("all") is True: + return _held(asked).read() + said: object = asked.body.get("ids") + if not isinstance(said, list) or not all( + isinstance(one, str | int) and not isinstance(one, bool) + for one in cast("list[object]", said) + ): + raise Refusal("Say ids as a list, or all as true.") + return _held(asked).read([str(one) for one in cast("list[object]", said)]) + + +def _prefers(asked: Asked) -> dict[str, Any]: + said = asked.body + if extra := sorted(set(said) - {"kinds", "system"}): + raise Refusal(f"Notifications take no {', '.join(extra)}.") + if not isinstance(said.get("kinds", {}), dict): + raise Refusal("kinds is whether each kind is told, by name.") + kinds = asked.named("kinds") + if extra := sorted(set(kinds) - set(KINDS)): + raise Refusal(f"There is no kind {', '.join(extra)}.") + if not all(isinstance(one, bool) for one in kinds.values()): + raise Refusal("Each kind is true or false.") + if "system" in said and not isinstance(said["system"], bool): + raise Refusal("system is true or false.") + + def change(held: dict[str, Any]) -> None: + now = preferences(held) + now["kinds"] |= kinds + if "system" in said: + now["system"] = said["system"] + held["notifications"] = now + + from hmz.coganchor import settings + + try: + return preferences(settings.changes(change)) + except OSError as why: + raise Refusal(f"The settings cannot be written: {why}", 409) from why + + +#: What a page reads of the notifications, and does to them. +ROUTES = routes( + ("GET", "/api/notifications", lambda asked: _held(asked).listed()), + ("GET", "/api/notifications/stream", lambda asked: _held(asked).stream()), + ("POST", "/api/notifications/read", _read), + ("POST", "/api/notifications/clear", lambda asked: _held(asked).clear()), + ("GET", "/api/notifications/settings", lambda _: preferences()), + ("POST", "/api/notifications/settings", _prefers), +) diff --git a/src/hmz/web/places.py b/src/hmz/web/places.py index 129f2b39..05df3a47 100644 --- a/src/hmz/web/places.py +++ b/src/hmz/web/places.py @@ -249,18 +249,43 @@ def _looking(self) -> None: if self._apart: self._asks_the_daemon() - def _asks_the_daemon(self) -> None: - """What the daemon says runs in each workspace no link here follows.""" - running: dict[str, dict[str, Any]] = {} + def statuses(self) -> dict[str, dict[str, Any]]: + """How the runs of every workspace held on this machine stand, attaching to none. + + Apart, what each host the daemon holds says of itself; otherwise, what the runs held + in this process say, of each workspace a page has reached. + + Returns: + Each host's status, by the whole path of its workspace. + """ + said: dict[str, dict[str, Any]] = {} + if not self._apart: + with self._lock: + hosts = [ + (str(one.workspace), one.hosting) + for one in self._places.values() + if one.hosting is not None + ] + for workspace, hosting in hosts: + with contextlib.suppress(Exception): + said[workspace] = hosting.status() + return said with contextlib.suppress(Exception): for one in self._daemon.daemons(): with contextlib.suppress(Exception): - status = one.status() - running[str(Path(one.workspace).resolve())] = { - "state": status.get("state") or "idle", - "run": status.get("run") or 0, - "flow": status.get("flow") or "", - } + said[str(Path(one.workspace).resolve())] = one.status() + return said + + def _asks_the_daemon(self) -> None: + """What the daemon says runs in each workspace no link here follows.""" + running = { + workspace: { + "state": status.get("state") or "idle", + "run": status.get("run") or 0, + "flow": status.get("flow") or "", + } + for workspace, status in self.statuses().items() + } with self._lock: self._running = running diff --git a/src/hmz/web/proxy.py b/src/hmz/web/proxy.py new file mode 100644 index 00000000..c09c0c68 --- /dev/null +++ b/src/hmz/web/proxy.py @@ -0,0 +1,137 @@ +"""A request of this interface's page, asked of another machine's interface instead. + +What a page asks of a daemon on another machine, under `/d//api/...`, is asked of that +machine's own interface at `/api/...`, through the port forwarded to it, and its answer handed +back as it comes -- a stream of events as each event comes, a file sent or downloaded a piece +at a time. The request is let in there as that interface's own page's: with its key, and saying +it comes from its own origin. Nothing of this interface's own key or cookies goes with it, and +nothing of what that one sets comes back. +""" + +from __future__ import annotations + +import contextlib +import http.client +from typing import TYPE_CHECKING + +from .routing import Refusal + +if TYPE_CHECKING: + from http.server import BaseHTTPRequestHandler + +__all__ = ["forwards"] + +#: What a request carries on: what it sends and what it asks for, never who it is. +_ASKED = ( + "Accept", + "Content-Type", + "Content-Length", + "If-None-Match", + "Last-Event-ID", + "X-Hmz-Filename", +) + +#: What an answer does not carry back: what is the connection's own, and the other's cookies. +_KEPT = frozenset( + { + "connection", + "keep-alive", + "transfer-encoding", + "set-cookie", + "server", + "date", + } +) + +#: What would end a header's line early, and so start another the other side never sent. +_SPLITS = frozenset("\r\n") + +#: How much is carried at a time. +_CHUNK = 1 << 16 + +#: How long, in seconds, the other side may say nothing before it is let go. A stream of +#: events says something more often than this. +_PATIENCE = 300.0 + + +def forwards( + handler: BaseHTTPRequestHandler, + port: int, + said: dict[str, str], + method: str, + path: str, +) -> None: + """Asks the interface on a port what a request asked, and answers the request with it. + + Args: + handler: The request, not yet answered. + port: Where the other interface is reached, on this machine's loopback. + said: What a request to it carries to be let in, by header. + method: The request's method. + path: What to ask for there: its path and query. + + Raises: + Refusal: If it could not be asked, before anything of an answer was sent. + """ + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=_PATIENCE) + try: + try: + answer = _asks(handler, connection, said, method, path) + except (OSError, http.client.HTTPException) as why: + raise Refusal("The daemon did not answer.", 502) from why + handler.send_response(answer.status, answer.reason) + for name, value in answer.getheaders(): + # A header that would break a line of this answer is not carried, whoever sent it. + if name.lower() not in _KEPT and not _SPLITS.intersection(name + value): + handler.send_header(name, value) + if answer.getheader("Content-Length") is None: + # Without a length, the answer ends where the connection does. + handler.close_connection = True + handler.end_headers() + while chunk := answer.read1(_CHUNK): + handler.wfile.write(chunk) + handler.wfile.flush() + except (OSError, http.client.HTTPException): + # Cut off once answering had begun: nothing is left to say but to stop. + handler.close_connection = True + finally: + connection.close() + + +def _asks( + handler: BaseHTTPRequestHandler, + connection: http.client.HTTPConnection, + said: dict[str, str], + method: str, + path: str, +) -> http.client.HTTPResponse: + """Sends the request on, what it sends a piece at a time, and reads how it is answered.""" + if handler.headers.get("Transfer-Encoding") is not None: + raise Refusal("Say how long what is sent is.", 411) + try: + length = int(handler.headers.get("Content-Length") or 0) + except ValueError as why: + raise Refusal("Say how long what is sent is.", 411) from why + if length < 0: + raise Refusal("Say how long what is sent is.", 411) + connection.putrequest(method, path, skip_host=True, skip_accept_encoding=True) + for name in _ASKED: + value = handler.headers.get(name) + if value is not None: + connection.putheader(name, value) + for name, value in said.items(): + connection.putheader(name, value) + connection.endheaders() + left = length + # An answer given before everything was sent -- a refusal -- is still the answer. + with contextlib.suppress(BrokenPipeError, ConnectionResetError): + while left: + chunk = handler.rfile.read(min(left, _CHUNK)) + if not chunk: + raise Refusal("What was sent was cut short.", 400) + connection.send(chunk) + left -= len(chunk) + if left: + # What was not read of what was sent is no next request. + handler.close_connection = True + return connection.getresponse() diff --git a/src/hmz/web/remote.py b/src/hmz/web/remote.py new file mode 100644 index 00000000..adb62409 --- /dev/null +++ b/src/hmz/web/remote.py @@ -0,0 +1,684 @@ +"""Daemons on other machines, reached over ssh and answered through this interface. + +Each daemon written down under `daemons` in humanize's settings is a host that `ssh` reaches. +What is reached there is that machine's own web interface: `hmz web --attached`, started over +ssh and stopped by the ssh going, with a port of this machine's loopback forwarded on to it. A +page asks this interface, which asks that one with its key: the remote interface keeps its own +key, its own runs and its own refusals, and nothing of it is served to anything but this page. + +An `hmz` missing there, or of another version than this one, is installed at this version with +`uv tool install`, uv itself fetched first where the host has none -- but never in place of one +the host's daemon may be running epics under, which keep running the release they started +with. A development build, `0.0.0`, installs nothing: it uses whatever `hmz` is there. + +A daemon is reached when a page first asks for it, or is told to connect, and reached again, +waiting longer each time, whenever it is lost. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import queue +import re +import socket +import subprocess +import threading +import time +from typing import TYPE_CHECKING, Any, cast + +from .routing import Refusal, routes + +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + from typing import IO + + from hmz.coganchor.transport import Road + + from .routing import Asked + +__all__ = ["ROUTES", "Remotes", "forwarding", "headers", "road", "said"] + +#: The release of uv fetched onto a host that has none, through astral's installer for it: +#: the one the desktop app pins. +UV = "0.12.24" + +#: What a daemon's state may be. +IDLE, CONNECTING, INSTALLING, UP, FAILED = ( + "idle", + "connecting", + "installing", + "up", + "failed", +) + +#: What every command there runs with: where `uv tool` and astral's installer put what they +#: install, which a shell that is not a login one may not have on its `PATH`. +_PATH = 'export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"; ' + +#: What is asked of a host before anything is started there: the version of its `hmz`, and +#: whether its daemon is running -- by the process `daemon.json` names in `humanize-` of +#: any temporary directory one may have been started with, as the desktop app tells -- +#: `unknown` where that cannot be read. +_PROBE = ( + _PATH + + r""" +echo "hmz: $(hmz --version 2>/dev/null | sed -n 's/^hmz //p' | tail -n 1)" +daemon=none +for under in "$TMPDIR" "$TEMP" "$TMP" "$(getconf DARWIN_USER_TEMP_DIR 2>/dev/null)" \ + /tmp /var/tmp; do + [ -n "$under" ] || continue + record="${under%/}/humanize-$(id -u)/daemon.json" + [ -e "$record" ] || continue + if [ ! -r "$record" ]; then daemon=unknown; continue; fi + pid=$(sed -n 's/.*"pid": *\([0-9][0-9]*\).*/\1/p' "$record" | head -n 1) + if [ -z "$pid" ]; then daemon=unknown + elif kill -0 "$pid" 2>/dev/null || ps -p "$pid" >/dev/null 2>&1; then + daemon=running; break + fi +done +echo "daemon: $daemon" +""" +) + +#: Installs `hmz` at a version, `{version}`, fetching uv first where there is none. +_INSTALL = _PATH + ( + "export UV_NO_MODIFY_PATH=1; " + "if ! command -v uv >/dev/null 2>&1; then " + f"echo 'Getting uv {UV}'; " + "if command -v curl >/dev/null 2>&1; " + f"then curl -LsSf https://astral.sh/uv/{UV}/install.sh | sh; " + f"else wget -qO- https://astral.sh/uv/{UV}/install.sh | sh; fi; " + "command -v uv >/dev/null 2>&1 || exit 1; fi; " + "exec uv tool install --force --python '>=3.12' 'hmz=={version}'" +) + +#: Starts the interface there, for as long as the ssh holding its standard input lives. +_SERVE = _PATH + "exec hmz web --no-open --attached --port 0" + +#: The line it says where it listens with. +_ADDRESS = re.compile(r"hmz web: http://127\.0\.0\.1:(\d+)/\?key=(\S+)") + +#: What `ssh` is told for every command here before what the host's own settings and humanize's +#: say beside them: nothing is asked of anybody, since nobody is at a terminal to answer. +_UNASKED = ("-o", "BatchMode=yes", "-o", "ConnectTimeout=20") + +#: How long, in seconds, asking a host what it has may take. +_PROBING = 60.0 + +#: How long installing `hmz` there may take. +_INSTALLING = 900.0 + +#: How long the interface there may take to say where it listens. +_STARTING = 60.0 + +#: How long the forwarded port may take to be listened on. +_FORWARDING = 20.0 + +#: How long a page asking for a daemon not yet reached waits for it. +_WAITING = 15.0 + +#: The wait before reaching a daemon again, doubled each time it fails, up to the last. +_BACKOFF = (1.0, 60.0) + +#: How long a process is given to stop before it is killed. +_STOPPING = 3.0 + + +class _Failed(Exception): # noqa: N818 -- the sentence a daemon's state says + """Reaching a daemon went wrong, as its state says it.""" + + +def _here_version() -> str: + """This humanize's version, as PyPI has it.""" + from importlib.metadata import version + + return version("hmz") + + +def _entries() -> list[dict[str, Any]]: + """Every daemon written down, as humanize's settings hold them.""" + from hmz.coganchor import settings + + said = settings.read().get("daemons") + if not isinstance(said, list): + return [] + return [ + cast("dict[str, Any]", one) + for one in cast("list[object]", said) + if isinstance(one, dict) + ] + + +def _entry(name: str) -> dict[str, Any] | None: + """The daemon written down by that name, or None.""" + if name == "local": # This machine, whatever is written down. + return None + return next((one for one in _entries() if one.get("name") == name), None) + + +def road(said: dict[str, Any]) -> Road: + """How a command is run on a daemon's host, as its entry says to reach it. + + Raises: + ValueError: For an entry that says no host, or says one wrongly. + """ + from hmz.coganchor.machines.store import SSHRuntime + from hmz.coganchor.transport import Road, Target, ssh_flags + + options: Any = said.get("options") or [] + if isinstance(options, dict): + options = cast("dict[Any, Any]", options).items() + try: + pairs = cast("list[tuple[Any, Any]]", list(options)) + runtime = SSHRuntime( + name="daemon", + host=str(said.get("host") or ""), + user=str(said.get("user") or ""), + port=int(said.get("port") or 0), + identity_file=str(said.get("identity_file") or ""), + proxy_jump=str(said.get("proxy_jump") or ""), + options={str(key): str(value) for key, value in pairs}, + alias=str(said.get("alias") or ""), + config=str(said.get("config") or ""), + ) + except (TypeError, ValueError) as why: + raise ValueError(str(why).removeprefix("daemon: ")) from why + target = Target( + "ssh", host=runtime.login(), port=runtime.port, options=runtime.settings() + ) + road = Road.to(target) + # After what the host's own settings say, which win, and before what humanize assumes. + own = 1 + len(ssh_flags(target.options)) + prefix = (*road.prefix[:own], *_UNASKED, *road.prefix[own:]) + return dataclasses.replace(road, prefix=prefix) + + +def forwarding(on: Road, local: int, remote: int) -> list[str]: + """The `ssh` that forwards a port of this machine's loopback to one of the host's. + + A connection of its own rather than one riding a shared master, whose forwards outlive + the client that asked for them; and one that lasts as long as its standard input, which + whatever started it holds, so that it goes with it however that goes. + """ + ssh, *options, host = on.prefix + alone = ( + ssh, + "-o", + "ControlMaster=no", + "-o", + "ControlPath=none", + "-o", + "ExitOnForwardFailure=yes", + *options, + "-L", + f"127.0.0.1:{local}:127.0.0.1:{remote}", + host, + ) + return dataclasses.replace(on, prefix=alone).line(["cat"]) + + +def said(lines: Sequence[str]) -> dict[str, str]: + """What a host answered the probe with, by what each line says it is.""" + found: dict[str, str] = {} + for line in lines: + key, colon, value = line.partition(":") + if colon and key in ("hmz", "daemon"): + found[key] = value.strip() + return found + + +def headers(port: int, key: str) -> dict[str, str]: + """What a request to an interface listening on `port` carries to be let in, as its page's.""" + return { + "Host": f"127.0.0.1:{port}", + "Origin": f"http://127.0.0.1:{port}", + "Sec-Fetch-Site": "same-origin", + "Cookie": f"hmz-{port}={key}", + } + + +def _free(wanted: int) -> int: + """A port free on this machine's loopback: `wanted` where it is, else any. + + The one the other interface listens on where it can be had, so that a cookie for one is + named as a cookie for the other. + """ + for port in (wanted, 0): + with socket.socket() as trying: + try: + trying.bind(("127.0.0.1", port)) + except OSError: + continue + return int(trying.getsockname()[1]) + raise _Failed("No port is free here to forward.") + + +def _last(lines: Sequence[str], otherwise: str) -> str: + """The last line said, or what is said otherwise.""" + return next((line for line in reversed(lines) if line.strip()), otherwise).strip() + + +class _Remote: + """One daemon, reached and reached again on a thread of its own until it is closed.""" + + def __init__(self, name: str, version: str, changed: threading.Condition) -> None: + self.name = name + self._version = version + self._changed = changed + self.state = IDLE + self.detail = "" + self.there = "" + self.port = 0 + self.remote = 0 + self.key = "" + self.entry: dict[str, Any] | None = None + #: What is said of it while it is up: why its hmz is not this one's, where it is not. + self._note = "" + self._children: set[subprocess.Popen[str]] = set() + self._closed = threading.Event() + self._again = threading.Event() + self._thread: threading.Thread | None = None + + def seen(self) -> dict[str, str]: + return {"state": self.state, "detail": self.detail, "version": self.there} + + def starts(self, said: dict[str, Any], *, now: bool) -> None: + """Reaches it, as `said` is its entry. + + Again at once where that has changed, or where it failed and is wanted `now` rather + than after its wait. + """ + if self._thread is None or not self._thread.is_alive(): + self._closed.clear() + self._thread = threading.Thread( + target=self._keeps, daemon=True, name=f"hmz-web-remote-{self.name}" + ) + self._thread.start() + elif (now and self.state == FAILED) or said != self.entry: + self._again.set() + + def close(self) -> None: + self._closed.set() + self._again.set() + with self._changed: + children = list(self._children) + for child in children: + _stops(child) + if self._thread is not None: + self._thread.join(timeout=_STOPPING * 3) + + def _says(self, state: str, detail: str = "") -> None: + with self._changed: + self.state, self.detail = state, detail + self._changed.notify_all() + + def _keeps(self) -> None: + """Reaches it, holds it while it lasts, and reaches it again after a wait.""" + wait = _BACKOFF[0] + while not self._closed.is_set(): + self._again.clear() + self._says(CONNECTING) + try: + web, forward = self._reaches() + except Exception as why: # noqa: BLE001 -- whatever it was is what it says + self._ends() + if self._closed.is_set(): + break + self._says(FAILED, str(why)) + else: + self._says(UP, self._note) + wait = _BACKOFF[0] + while not self._again.wait(0.25): + if web.poll() is not None or forward.poll() is not None: + break + self._ends() + if self._closed.is_set(): + break + if self._again.is_set(): + continue + self._says(CONNECTING, "Lost") + self._again.wait(wait) + wait = min(wait * 2, _BACKOFF[1]) + self._says(IDLE) + + def _reaches(self) -> tuple[subprocess.Popen[str], subprocess.Popen[str]]: + """Reaches it. + + Probes the host, installs `hmz` there where it must, starts the interface there and + forwards a port to it. + + Returns: + The interface there, and the forward. + + Raises: + _Failed: Saying what went wrong. + """ + self.entry = _entry(self.name) + if self.entry is None: + raise _Failed(f"No daemon is called {self.name}.") + try: + on = road(self.entry) + except ValueError as why: + raise _Failed(str(why)) from why + version = self._installed(on) + self.there = version + web = self._started(on.line(["sh", "-c", _SERVE]), apart=True) + address = self._listens(web) + self.remote, self.key = int(address[1]), address[2] + self.port = _free(self.remote) + forward = self._started(forwarding(on, self.port, self.remote)) + told = _drained(forward) + ending = time.monotonic() + _FORWARDING + while not _listened(self.port): + if forward.poll() is not None: + time.sleep(0.1) # For what it said last to be read. + raise _Failed(_last(told, "The port could not be forwarded.")) + if self._closed.is_set() or time.monotonic() > ending: + raise _Failed("The port was not forwarded in time.") + time.sleep(0.05) + return web, forward + + def _installed(self, on: Road) -> str: + """The version of `hmz` there, once it is this one where it may be made so.""" + self._note = "" + found = self._probed(on) + there = found.get("hmz", "") + wanted = self._version + if wanted in ("0.0.0", there): + if not there: + raise _Failed(f"hmz is not installed on {self.name}.") + return there + if there and found.get("daemon") != "none": + if found.get("daemon") == "running": + why = f"hmz {there} there holds running epics; hmz {wanted} waits" + else: + why = f"Whether hmz {there} there runs epics is unknown; not replaced" + self._note = why + return there + self._says(INSTALLING, f"hmz {wanted}") + lines: list[str] = [] + installing = self._started( + on.line(["sh", "-c", _INSTALL.format(version=wanted)]) + ) + for line in _lines(installing, _INSTALLING): + lines.append(line) + if line.strip(): + self._says(INSTALLING, line.strip()) + if installing.wait() != 0: + raise _Failed(_last(lines, f"hmz {wanted} could not be installed.")) + there = self._probed(on).get("hmz", "") + if there != wanted: + raise _Failed(f"hmz {wanted} was installed, and hmz {there} answers.") + return there + + def _probed(self, on: Road) -> dict[str, str]: + probing = self._started(on.line(["sh", "-c", _PROBE])) + lines = list(_lines(probing, _PROBING)) + if probing.wait() != 0 or "daemon" not in said(lines): + raise _Failed(_last(lines, f"{self.name} did not answer.")) + return said(lines) + + def _listens(self, web: subprocess.Popen[str]) -> re.Match[str]: + """Where the interface there listens, once it says.""" + stdout = web.stdout + assert stdout is not None # noqa: S101 -- it was started with one + assert web.stderr is not None # noqa: S101 + heard: queue.Queue[str | None] = queue.Queue() + errors = _drained(web, web.stderr) + + def reads() -> None: + # Read to its end, which it says nothing more before. + with contextlib.suppress(OSError, ValueError), stdout: + for line in stdout: + heard.put(line) + heard.put(None) + + threading.Thread(target=reads, daemon=True, name="hmz-web-remote").start() + ending = time.monotonic() + _STARTING + while True: + try: + line = heard.get(timeout=min(1.0, max(0.0, ending - time.monotonic()))) + except queue.Empty: + if time.monotonic() >= ending: + raise _Failed( + _last(errors, "hmz web there did not say where it listens.") + ) from None + # What it says while it comes up, said as how it stands. + if errors: + self._says(CONNECTING, _last(errors, "")) + continue + if line is None: + web.wait() + raise _Failed(_last(errors, "hmz web there stopped.")) + found = _ADDRESS.search(line) + if found: + return found + + def _started( + self, argv: list[str], *, apart: bool = False + ) -> subprocess.Popen[str]: + """A process, which is stopped as this daemon is closed or lost. + + Its standard input is held open until then, so that it goes with this one however + that goes; what it says on stderr is read `apart` from its stdout, or with it. + """ + with self._changed: + if self._closed.is_set(): + raise _Failed("Closed.") + try: + child = subprocess.Popen( + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE if apart else subprocess.STDOUT, + text=True, + errors="replace", + start_new_session=True, + ) + except OSError as why: + raise _Failed(f"ssh could not be run: {why}") from why + self._children.add(child) + return child + + def _ends(self) -> None: + """Stops and reaps every process started for it.""" + with self._changed: + children = list(self._children) + self._children.clear() + for child in children: + _stops(child) + + +def _lines(child: subprocess.Popen[str], patience: float) -> Iterator[str]: + """Each line a process says, until it ends or has taken longer than `patience`.""" + assert child.stdout is not None # noqa: S101 -- it was started with one + timer = threading.Timer(patience, child.kill) + timer.start() + try: + with child.stdout: + yield from (line.rstrip("\n") for line in child.stdout) + finally: + timer.cancel() + + +def _drained(child: subprocess.Popen[str], said: IO[str] | None = None) -> list[str]: + """The last lines a process says, read as it says them so that it is never held up. + + Args: + child: The process. + said: What of it is read: its stdout, by default. + """ + lines: list[str] = [] + held = said or child.stdout + assert held is not None # noqa: S101 -- it was started with one + + def drains() -> None: + with held, contextlib.suppress(OSError, ValueError): + for line in held: + lines.append(line.rstrip("\n")) + del lines[:-20] + + threading.Thread(target=drains, daemon=True, name="hmz-web-remote").start() + return lines + + +def _listened(port: int) -> bool: + """Whether something listens on a port of this machine's loopback.""" + with socket.socket() as trying: + trying.settimeout(1.0) + return trying.connect_ex(("127.0.0.1", port)) == 0 + + +def _stops(child: subprocess.Popen[str]) -> None: + """Stops a process and reaps it: its standard input closed, then terminated, then killed.""" + if child.stdin is not None: + with contextlib.suppress(OSError): + child.stdin.close() + with contextlib.suppress(subprocess.TimeoutExpired): + child.wait(timeout=1.0) + if child.poll() is None: + with contextlib.suppress(OSError): + child.terminate() + try: + child.wait(timeout=_STOPPING) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + + +class Remotes: + """Every daemon on another machine, as each is reached. + + Args: + version: The version of `hmz` installed where another is found; this one's by default. + """ + + def __init__(self, version: str | None = None) -> None: + self._version = version or _here_version() + self._changed = threading.Condition() + self._held: dict[str, _Remote] = {} + self._closed = False + + def state(self, name: str) -> dict[str, str]: + """How a daemon stands: `{state, detail, version}`. + + Its state is `idle` where it has not been asked for, then `connecting`, `installing`, + `up` or `failed`. + """ + with self._changed: + held = self._held.get(name) + return held.seen() if held else {"state": IDLE, "detail": "", "version": ""} + + def connect(self, name: str) -> dict[str, str]: + """Starts reaching a daemon, or reaches it again at once where it failed. + + Returns: + How it stands. + + Raises: + Refusal: If no daemon is written down by that name. + """ + self._starts(name, now=True) + return self.state(name) + + def reached(self, name: str) -> tuple[int, str]: + """Where a daemon is reached here, and the key it lets in. + + Reached first where it was not, waiting a while for it. + + Returns: + The port of this machine's loopback forwarded to it, and its key. + + Raises: + Refusal: If it is not up, with its state; or if none is called that. + """ + held = self._up(name) + return held.port, held.key + + def asks(self, name: str) -> tuple[int, dict[str, str]]: + """Where a daemon is reached here, and what a request to it carries to be let in. + + Raises: + Refusal: As `reached` does. + """ + held = self._up(name) + return held.port, headers(held.remote, held.key) + + def close(self) -> None: + """Stops reaching every daemon, every process started for one stopped and reaped.""" + with self._changed: + self._closed = True + held = list(self._held.values()) + stopping = [threading.Thread(target=one.close) for one in held] + for one in stopping: + one.start() + for one in stopping: + one.join() + + def _starts(self, name: str, *, now: bool) -> _Remote: + """A daemon, being reached: again where what reaches it has changed. + + Raises: + Refusal: If none is written down by that name -- one that was is let go of. + """ + said = _entry(name) + with self._changed: + if self._closed: + raise Refusal("This interface is stopping.", 503) + if said is None: + gone = self._held.pop(name, None) + if gone is not None: + threading.Thread(target=gone.close, daemon=True).start() + raise Refusal("There is no daemon by that name.", 404) + held = self._held.setdefault( + name, _Remote(name, self._version, self._changed) + ) + held.starts(said, now=now) + return held + + def _up(self, name: str) -> _Remote: + """A daemon once it is up, waiting a while for one being reached. + + Raises: + Refusal: If it is not up, with its state; or if none is called that. + """ + held = self._starts(name, now=False) + with self._changed: + # Not past a failure, which is reached again after its wait rather than at once. + self._changed.wait_for( + lambda: held.state in (UP, FAILED, INSTALLING), timeout=_WAITING + ) + if held.state == UP: + return held + raise Refusal( + held.detail or f"{name} is not reached yet.", 503, **held.seen() + ) + + +def _local() -> dict[str, str]: + return {"state": UP, "detail": "", "version": _here_version()} + + +def _remotes(asked: Asked) -> Remotes: + """The daemons on other machines, where the interface reaches any by the name asked.""" + if asked.site.remotes is None or _entry(asked.matched["name"]) is None: + raise Refusal("There is no daemon by that name.", 404) + return asked.site.remotes + + +def _state(asked: Asked) -> dict[str, str]: + name = asked.matched["name"] + return _local() if name == "local" else _remotes(asked).state(name) + + +def _connect(asked: Asked) -> dict[str, str]: + name = asked.matched["name"] + return _local() if name == "local" else _remotes(asked).connect(name) + + +#: How a page reads how a daemon stands, and has it reached. +ROUTES = routes( + ("GET", "/api/daemons/{name}/state", _state), + ("POST", "/api/daemons/{name}/connect", _connect), +) diff --git a/src/hmz/web/site.py b/src/hmz/web/site.py index e6204457..2285eed9 100644 --- a/src/hmz/web/site.py +++ b/src/hmz/web/site.py @@ -25,6 +25,7 @@ import secrets import shutil import socket +import socketserver import string import sys import tempfile @@ -39,11 +40,16 @@ from . import ( accounts, + daemons, fallbacks, flows, held, + notices, + notifications, paths, places, + proxy, + remote, runs, runtimes, settings, @@ -54,7 +60,10 @@ if TYPE_CHECKING: from collections.abc import Generator + from .notices import Notices + from .notifications import Notifications from .places import Places + from .remote import Remotes from .runs import Runs __all__ = ["Site"] @@ -68,11 +77,18 @@ *accounts.ROUTES, *fallbacks.ROUTES, *runtimes.ROUTES, + *daemons.ROUTES, *paths.ROUTES, *places.ROUTES, *verses.ROUTES, + *notices.ROUTES, + *remote.ROUTES, + *notifications.ROUTES, ) +#: What a daemon is called in the paths a page asks another machine's daemon by. +_DAEMON = frozenset(string.ascii_letters + string.digits + "._-") + #: How many ports any would do for are tried before one free on both loopbacks is given up on. _PORTS = 8 @@ -120,7 +136,21 @@ _LONGEST = 200 -class Site(ThreadingHTTPServer): +class _Bound(ThreadingHTTPServer): + """A server bound without asking the network what its loopback is called. + + `HTTPServer.server_bind` looks the address up by reverse DNS, for a name this interface + never uses -- a lookup that can hang for half a minute where the resolver is slow. + """ + + def server_bind(self) -> None: + socketserver.TCPServer.server_bind(self) + host, port = self.server_address[:2] + self.server_name = str(host) + self.server_port = int(port) + + +class Site(_Bound): """The server, holding what its routes are answered from. Listens on IPv4's loopback itself and on IPv6's beside it, on the same port, so that a @@ -131,12 +161,15 @@ class Site(ThreadingHTTPServer): port: The port to listen on, or 0 for any free one on both. places: Every workspace on this machine, and the runs held for each as this follows them. runs: The runs written down, of every workspace. + notices: What happened to the runs worth telling somebody about. """ daemon_threads = True allow_reuse_address = True - def __init__(self, port: int, *, places: Places, runs: Runs) -> None: + def __init__( + self, port: int, *, places: Places, runs: Runs, notices: Notices + ) -> None: self._beside: _Beside | None = None for left in reversed(range(_PORTS)): super().__init__(("127.0.0.1", port), _Handler) @@ -152,6 +185,11 @@ def __init__(self, port: int, *, places: Places, runs: Runs) -> None: self.places = places self.hmz: Any = places.hmz self.runs = runs + self.notices = notices + #: The daemons on other machines, as each is reached, once `listening` has them. + self.remotes: Remotes | None = None + #: Every daemon's notices, as this interface tells them, once it is listening. + self.notifications: Notifications | None = None #: Set as the server stops, which is what ends every stream it is sending. self.going = threading.Event() self._key = secrets.token_urlsafe(32) @@ -204,7 +242,7 @@ def server_close(self) -> None: self._beside.server_close() -class _Beside(ThreadingHTTPServer): +class _Beside(_Bound): """IPv6's loopback, answered as the site it stands beside answers IPv4's. Args: @@ -282,8 +320,13 @@ def _routes(self, method: str, path: str, query: dict[str, str]) -> None: raise Refusal( "Open the address hmz web printed, on the machine it runs on.", 403 ) + # This machine's own daemon, named as a page names every daemon it asks. + if path.startswith("/d/local/api/"): + path = path.removeprefix("/d/local") if path.startswith("/api/"): self._api(method, path, query) + elif path.startswith("/d/"): + self._daemon(method, path, query) elif method != "GET": raise Refusal("Only the page's own files are here.", 405) elif path == "/" and "key" in query: @@ -352,13 +395,39 @@ def _signs_in(self, key: str) -> None: ) self._ends(0) - def _api(self, method: str, path: str, query: dict[str, str]) -> None: + def _admits(self) -> None: + """Refuses a request to the API from a browser not let in, or from another page.""" if not self._signed_in(): raise Refusal( "Open the address hmz web printed to let this browser in.", 401 ) if not self._asked_here(): raise Refusal("Use this server's own page to make this request.", 403) + + def _daemon(self, method: str, path: str, query: dict[str, str]) -> None: + """The API of a daemon by name, `/d//api/...`. + + This machine's own for `local`, and otherwise asked of the interface on the machine + it names. + """ + name, _, rest = path.removeprefix("/d/").partition("/") + rest = f"/{rest}" + if not name or not set(name) <= _DAEMON or not rest.startswith("/api/"): + raise Refusal("There is nothing here by that name.", 404) + if name == "local": + self._api(method, rest, query) + return + self._admits() + if method == "POST" and not self._from_its_page(): + raise Refusal("Use this server's own page to make this request.", 403) + if self.site.remotes is None: + raise Refusal("There is no daemon by that name.", 404) + port, said = self.site.remotes.asks(name) + asked = urllib.parse.urlsplit(self.path).query + proxy.forwards(self, port, said, method, rest + (f"?{asked}" if asked else "")) + + def _api(self, method: str, path: str, query: dict[str, str]) -> None: + self._admits() for route in ROUTES: matched = route.match(method, path) if matched is None: @@ -529,6 +598,9 @@ def _downloads(self, answer: Download) -> None: def _streams(self, answer: Stream, query: dict[str, str]) -> None: """Server-sent events, for as long as the browser listens and the server is up.""" last = self.headers.get("Last-Event-ID") or query.get("last", "") + # Where it starts from is settled before the browser hears it is answered: what comes + # once it has heard so is never before the start. + events = answer.events(last, self.site.going) self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Cache-Control", "no-store") @@ -539,7 +611,7 @@ def _streams(self, answer: Stream, query: dict[str, str]) -> None: self.close_connection = True self.wfile.write(b"retry: 2000\n\n") self.wfile.flush() - for said in answer.events(last, self.site.going): + for said in events: if said is None: self.wfile.write(b": quiet\n\n") else: diff --git a/src/hmz/web/static/api.js b/src/hmz/web/static/api.js index 1bdd983b..7df0421d 100644 --- a/src/hmz/web/static/api.js +++ b/src/hmz/web/static/api.js @@ -1,6 +1,68 @@ // How the page asks the server anything: one wrapper for reading and writing, which turns a // refusal into the sentence the server gave, and one store for what is read again and again, -// shared by every view of it. +// shared by every view of it. Everything is asked of the daemon the address names -- `#/@/…`, +// Local where it names none -- under `/d//api/…`, save what is the same for every daemon. + +/** What is asked of Local whichever daemon the page is on: the daemons, and the notifications. */ +const GLOBAL = /^\/api\/(daemons|notifications)(\/|\?|$)/ + +/** The daemon the address names, or '' where it names none. */ +function named(address) { + const match = /^#?\/?@([^/?]*)/.exec(address) + try { + return match ? decodeURIComponent(match[1]) : '' + } catch { + return match[1] + } +} + +let naming = named(location.hash) +/** How many times the page has switched daemons: what was asked before the last is not told. */ +let era = 0 +const switched = new Set() + +/** The daemon the page is on. */ +export function daemon() { + return naming || 'local' +} + +/** Where an address of the API is asked: of the daemon the page is on, unless it is global. */ +export function scoped(path) { + return path.startsWith('/api/') && !GLOBAL.test(path) ? `/d/${encodeURIComponent(daemon())}${path}` : path +} + +/** The address of a view of the page, on the daemon the page is on, as the address names it. */ +export function hash(path) { + if (path.startsWith('#')) path = path.slice(1) + if (path.startsWith('/@')) return `#${path}` + return naming ? `#/@${encodeURIComponent(naming)}${path}` : `#${path}` +} + +/** Switches the page to another daemon, on the view given, a new epic where none is. */ +export function toDaemon(name, path = '/new') { + location.hash = name === 'local' ? path : `/@${encodeURIComponent(name)}${path}` +} + +/** Tells `fn` the daemon the page switched to, each time it does, until what it returns is called. */ +export function onDaemon(fn) { + switched.add(fn) + return () => switched.delete(fn) +} + +// Ahead of the page's own routing, which is told by the event to draw everything again. +window.addEventListener('hashchange', () => { + const was = daemon() + naming = named(location.hash) + if (daemon() === was) return + era += 1 + // What was read of the last daemon is not this one's: read again, once whoever leaves has. + for (const read of reads.values()) Object.assign(read, { text: undefined, data: undefined, error: null, failures: 0, stale: true }) + setTimeout(() => { + for (const read of reads.values()) if (read.stale) fetchNow(read) + }) + for (const fn of [...switched]) fn(daemon()) + window.dispatchEvent(new CustomEvent('hmz:daemon', { detail: { daemon: daemon() } })) +}) /** A refusal: the server's own sentence, the status it came with, and what else it said. */ export class Refused extends Error { @@ -13,13 +75,13 @@ export class Refused extends Error { /** Reads one answer, or throws the server's sentence. */ export async function get(url) { - return answered(await fetch(url, { headers: { Accept: 'application/json' } })) + return answered(await fetch(scoped(url), { headers: { Accept: 'application/json' } })) } /** Writes, sending JSON, and reads the answer, or throws the server's sentence. */ export async function post(url, body = {}) { return answered( - await fetch(url, { + await fetch(scoped(url), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), @@ -30,7 +92,7 @@ export async function post(url, body = {}) { /** Sends a file as it is, under its name, and reads the answer, or throws the server's sentence. */ export async function send(url, file, kind) { return answered( - await fetch(url, { + await fetch(scoped(url), { method: 'POST', headers: { 'Content-Type': kind, 'X-Hmz-Filename': encodeURIComponent(file.name), Accept: 'application/json' }, body: file, @@ -97,12 +159,15 @@ export function refresh(url) { async function fetchNow(read) { clearTimeout(read.timer) + read.stale = false // A page nobody looks at reads nothing again until looked at -- but what it has never read // it reads, so a page opened in a tab behind is drawn once it is looked at. if (document.hidden && read.text !== undefined) return + const mine = era try { - const response = await fetch(read.url, { headers: { Accept: 'application/json' } }) + const response = await fetch(scoped(read.url), { headers: { Accept: 'application/json' } }) const text = await response.text() + if (mine !== era) return if (response.status === 401) window.dispatchEvent(new CustomEvent('hmz:out')) if (!response.ok) { let said = null @@ -122,6 +187,7 @@ async function fetchNow(read) { for (const watcher of read.watchers) watcher.told(read.data, null) } } catch (error) { + if (mine !== era) return read.failures += 1 read.error = error for (const watcher of read.watchers) watcher.told(read.data, error) diff --git a/src/hmz/web/static/app.js b/src/hmz/web/static/app.js index 12da1ee4..6d976281 100644 --- a/src/hmz/web/static/app.js +++ b/src/hmz/web/static/app.js @@ -2,9 +2,10 @@ // epics on the left, and whichever view the address names beside it -- a new epic, its graph, one session's // thread, the flows -- with finding a run and the settings opened over it as dialogs. Routed // by the address's fragment, so that every view of the page is an address that can be kept, -// shared or opened again, and the server serves one page for all of them. +// shared or opened again, and the server serves one page for all of them. An address names the +// daemon it is of first, `#/@/…`, or names none and is of Local. -import { get } from './api.js' +import { daemon, get, hash } from './api.js' import { fill, h, problem } from './dom.js' import { mountSidebar } from './sidebar.js' import { kept, toggleSidebar } from './shell.js' @@ -44,16 +45,16 @@ let behind = '' /** What the view drawn was drawn for, so a dialog opened over it leaves it standing. */ let shown = '' -/** Where the page is: the parts of the address's path, and its query. */ +/** Where the page is: the parts of the address's path, after the daemon it names, and its query. */ export function where() { - const raw = location.hash.replace(/^#/, '') || '/new' + const raw = location.hash.replace(/^#/, '').replace(/^\/?@[^/?]*/, '') || '/new' const url = new URL(raw.startsWith('/') ? raw : `/${raw}`, 'http://page') return { parts: url.pathname.split('/').filter(Boolean).map(decodeURIComponent), query: url.searchParams } } -/** Goes to another view of the page. */ +/** Goes to another view of the page, on the daemon it is on. */ export function go(path) { - location.hash = path + location.hash = hash(path) } /** Puts the query of the view the page is on in the address, without a step in its history. */ @@ -68,7 +69,7 @@ export function stays(query) { const said = new URLSearchParams() for (const [name, value] of Object.entries(query)) if (value !== '' && value !== undefined && value !== null) said.set(name, value) const text = said.toString() - history.replaceState(null, '', `#/${parts.map(encodeURIComponent).join('/')}${text ? `?${text}` : ''}`) + history.replaceState(null, '', hash(`/${parts.map(encodeURIComponent).join('/')}${text ? `?${text}` : ''}`)) routedTo = location.hash } @@ -222,14 +223,36 @@ window.addEventListener('hmz:out', () => { ) }) +// Another daemon: everything drawn of the last is drawn again of this one, as the address +// routed next says -- told before that routing, which api.js is told of first. +window.addEventListener('hmz:daemon', () => { + if (mounted) mounted.leave() + mounted = null + shown = '' + opened = '' + behind = '' + named().catch(() => {}) +}) + +/** Names the page by the machine the daemon it is on is. */ +function named() { + const asked = daemon() + return get('/api/held').then((context) => { + if (asked === daemon()) document.title = `humanize · ${context.machine}` + return context + }) +} + window.addEventListener('hashchange', route) -get('/api/held') +named() .then((context) => { - document.title = `humanize · ${context.machine}` mountSidebar(document.getElementById('sidebar'), context, where) route() }) .catch((error) => { - if (!out) fill(page, h('div', { class: 'view' }, problem(error))) + if (out) return + // A daemon out of reach: said where the view would be, the sidebar there to go elsewhere. + mountSidebar(document.getElementById('sidebar'), {}, where) + fill(page, h('div', { class: 'view' }, problem(error))) }) diff --git a/src/hmz/web/static/dom.js b/src/hmz/web/static/dom.js index 2f767c3d..e3cf55f5 100644 --- a/src/hmz/web/static/dom.js +++ b/src/hmz/web/static/dom.js @@ -250,6 +250,7 @@ const ICONS = { home: ['M3 11l9-8 9 8', 'M5 10v10h14V10'], copy: ['M9 9h11v11H9Z', 'M5 15H4V4h11v1'], open: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'], + bell: ['M6 16v-5a6 6 0 0 1 12 0v5l2 2H4Z', 'M10 21h4'], } /** An icon, drawn in the colour of the text around it and said by nothing: its button says it. */ diff --git a/src/hmz/web/static/graph.js b/src/hmz/web/static/graph.js index bebabf55..03924578 100644 --- a/src/hmz/web/static/graph.js +++ b/src/hmz/web/static/graph.js @@ -4,6 +4,7 @@ // role to another, as many times as it did. While the run goes, the node working is lit, the // last handover is the red one, and a line on every lane says where now is. +import { hash } from './api.js' import { flowName, h, iconButton, lasting, svg, when } from './dom.js' import { copy, menuOn } from './ui.js' @@ -285,7 +286,7 @@ export function graph({ open }) { } function agentNode(agent, box, focused, epic, going) { - const base = `#/epics/${encodeURIComponent(epic)}` + const base = hash(`/epics/${encodeURIComponent(epic)}`) const head = h( 'button', { class: 'node-head', type: 'button', 'aria-pressed': String(focused) }, diff --git a/src/hmz/web/static/held.js b/src/hmz/web/static/held.js index 73ea02bf..391748a6 100644 --- a/src/hmz/web/static/held.js +++ b/src/hmz/web/static/held.js @@ -3,7 +3,7 @@ // out -- the same the terminal interface draws. A workspace is followed only while a view of it // is open. What a view asks of a workspace's runs goes through `ask`. -import { post } from './api.js' +import { onDaemon, post, scoped } from './api.js' /** The most records kept per workspace, and about how many bytes of them, as the server keeps. */ const KEPT = 20000 @@ -38,7 +38,7 @@ export function follow(workspace, told) { setTimeout(() => { if (one.listeners.size) return one.close() - followed.delete(workspace) + if (followed.get(workspace) === one) followed.delete(workspace) }, 5000) } } @@ -77,7 +77,7 @@ function following(workspace) { function connect() { const query = new URLSearchParams({ workspace }) if (lastId && state.epoch) query.set('last', lastId) - const mine = new EventSource(`/api/held/stream?${query}`) + const mine = new EventSource(scoped(`/api/held/stream?${query}`)) one.source = mine // A stream let go of, or replaced, says nothing more to the page. const on = (name, handle) => mine.addEventListener(name, (event) => one.source === mine && handle(event)) @@ -145,6 +145,15 @@ function following(workspace) { return one } +// Another daemon's workspaces are others: every stream of the last goes, and nobody is told it. +onDaemon(() => { + for (const one of followed.values()) { + one.listeners.clear() + one.close() + } + followed.clear() +}) + // A browser holds only so many connections to one server: a tab nobody looks at lets its // streams go after a while, and picks each up from the last record it heard once looked at. let resting = 0 diff --git a/src/hmz/web/static/importing.js b/src/hmz/web/static/importing.js index b7ca6ea7..d62e7782 100644 --- a/src/hmz/web/static/importing.js +++ b/src/hmz/web/static/importing.js @@ -2,7 +2,7 @@ // the disk, or dropped anywhere on the window, and opened once it is in. One already on this // machine, named by its path -- as the desktop app opens a file -- is `#/epics/import?path=`. -import { refresh, send } from './api.js' +import { daemon, hash, refresh, send } from './api.js' import { h, iconButton, problem } from './dom.js' /** What an epic's archive is named with, and sent as. */ @@ -29,18 +29,21 @@ function failed(...why) { /** How an import refused is said: the server's sentence, and the epic it names where that is here already. */ export function refusal(error) { const name = error.said && error.said.name - return [problem(error), name ? h('a', { href: `#/epics/${encodeURIComponent(name)}`, 'data-hmz': 'imported-already', onclick: () => say() }, 'Open') : null] + return [problem(error), name ? h('a', { href: hash(`/epics/${encodeURIComponent(name)}`), 'data-hmz': 'imported-already', onclick: () => say() }, 'Open') : null] } /** Sends one archive, saying so as it goes, and opens the epic it was once it is in. */ export async function importFile(file) { if (file.size > LARGEST) return failed(problem('That is more than a request here may send.')) say(h('span', {}, `Importing ${file.name}…`)) + const into = daemon() try { const answer = await send('/api/runs/import', file, KIND) say() + // Opened on the daemon it went into, unless the page has since gone to another. + if (into !== daemon()) return refresh('/api/runs') - location.hash = `/epics/${encodeURIComponent(answer.name)}` + location.hash = hash(`/epics/${encodeURIComponent(answer.name)}`) } catch (error) { failed(refusal(error)) } diff --git a/src/hmz/web/static/notifications.js b/src/hmz/web/static/notifications.js new file mode 100644 index 00000000..8e425f03 --- /dev/null +++ b/src/hmz/web/static/notifications.js @@ -0,0 +1,191 @@ +// The notifications, of every daemon at once rather than the one shown: a bell with how many are +// unread, a menu of them newest first, a card for each as it comes, and -- while the page is not +// looked at -- one the system shows, by the macOS app or by the browser. Each opens what it is +// about: the epic going, for a question or a chat's next turn, or the epic that ended. + +import { get, post } from './api.js' +import { ago, fill, h, headline, icon, iconButton } from './dom.js' +import { inApp, menu, section } from './shell.js' + +/** What each kind is called, and the mark it is drawn with. */ +const KINDS = { + done: ['Done', '✔', 'done'], + failed: ['Failed', '✕', 'failed'], + stopped: ['Stopped', '■', 'stopped'], + asked: ['Question', '?', 'running'], + listening: ['Chat turn', '›', 'running'], +} + +/** How long a card stays, and how many stand at once. */ +const SHOWN = 6000 +const CARDS = 3 + +/** The notifications held, newest first. */ +let items = [] +let button = null +let count = null +let drawMenu = null +let cards = null + +/** The bell, made once and the feed started with it: hidden while the server has no notifications. */ +export function bell() { + if (button) return button + count = h('span', { class: 'notify-count', 'data-hmz': 'notification-count', hidden: true }) + button = h('button', { class: 'icon-btn notify-bell', type: 'button', 'aria-label': 'Notifications', title: 'Notifications', 'data-hmz': 'notifications', hidden: true }, icon('bell'), count) + menu(button, (close) => { + const list = h('div', { class: 'notify-list' }) + drawMenu = () => drawList(list, close) + drawMenu() + const read = h('button', { class: 'btn quiet small', type: 'button', 'data-hmz': 'notifications-read', onclick: () => markRead(null) }, 'Mark read') + const clear = h('button', { class: 'btn quiet small', type: 'button', 'data-hmz': 'notifications-clear', onclick: clearAll }, 'Clear') + return [h('div', { class: 'notify-menu', 'data-hmz': 'notification-menu' }, h('div', { class: 'notify-head' }, section('Notifications'), read, clear), list)] + }, { label: 'Notifications' }) + start() + return button +} + +/** How long a failed read waits before the next, which doubles up to five minutes. */ +let wait = 10000 +/** What was heard since the list was last asked for, which an older answer must not drop. */ +let fresh = new Map() + +async function start() { + try { + await reread() + } catch (error) { + // A server without notifications has no bell, nor a browser not let in; else it is asked again. + if (error.status !== 404 && error.status !== 401) { + setTimeout(start, wait) + wait = Math.min(wait * 2, 300000) + } + return + } + button.hidden = false + const stream = new EventSource('/api/notifications/stream') + stream.addEventListener('notification', (event) => heard(JSON.parse(event.data))) + // Read again whenever the stream opens, for what came before it did, and as the page is + // looked at again, for what another tab read or cleared. + stream.addEventListener('open', () => reread().catch(() => {})) + document.addEventListener('visibilitychange', () => document.hidden || reread().catch(() => {})) +} + +async function reread() { + fresh = new Map() + const answer = await get('/api/notifications') + const known = new Set((answer.items || []).map((one) => one.id)) + items = [...[...fresh.values()].reverse().filter((one) => !known.has(one.id)), ...(answer.items || [])] + draw() +} + +function heard(item) { + fresh.set(item.id, item) + if (items.some((one) => one.id === item.id)) return + items.unshift(item) + draw() + card(item) + system(item) +} + +function draw() { + const unread = items.filter((one) => !one.read).length + count.hidden = !unread + count.textContent = unread > 99 ? '99+' : String(unread) + if (drawMenu && button.getAttribute('aria-expanded') === 'true') drawMenu() +} + +function drawList(list, close) { + if (!items.length) return fill(list, h('p', { class: 'notify-none' }, 'Nothing yet')) + fill( + list, + items.map((item) => + h( + 'button', + { class: ['choice', 'notify-item', !item.read && 'notify-unread'], type: 'button', role: 'menuitem', 'data-hmz': 'notification', onclick: () => (close(), opens(item)) }, + mark(item), + h('span', { class: 'choice-text' }, h('span', { class: 'choice-label' }, `${kindOf(item)} · ${daemonOf(item)}`), h('span', { class: 'choice-about' }, about(item)), h('span', { class: 'notify-when' }, ago(item.at))), + ), + ), + ) +} + +function mark(item) { + const [, glyph, tone] = KINDS[item.kind] || ['', '·', 'idle'] + return h('span', { class: `choice-mark tone-${tone}`, 'aria-hidden': 'true' }, h('span', { class: 'glyph' }, glyph)) +} + +const kindOf = (item) => (KINDS[item.kind] || [item.kind])[0] +const daemonOf = (item) => (!item.daemon || item.daemon === 'local' ? 'Local' : item.daemon) +const about = (item) => [item.flow, item.text || (item.task ? headline(item.task) : item.epic)].filter(Boolean).join(' · ') + +/** Where a notification opens: the epic going for what waits on a person, else the epic itself. */ +export function addressOf(item) { + const at = !item.daemon || item.daemon === 'local' ? '#/' : `#/@${encodeURIComponent(item.daemon)}/` + if ((item.kind === 'asked' || item.kind === 'listening' || !item.epic) && item.workspace) return `${at}live?${new URLSearchParams({ workspace: item.workspace })}` + return item.epic ? `${at}epics/${encodeURIComponent(item.epic)}` : `${at}live` +} + +function opens(item) { + if (items.some((one) => one.id === item.id && !one.read)) markRead([item.id]) + location.hash = addressOf(item) +} + +async function markRead(ids) { + for (const one of items) if (!ids || ids.includes(one.id)) one.read = true + draw() + try { + await post('/api/notifications/read', ids ? { ids } : { all: true }) + } catch { + // Read here alone, until the list is read again. + } +} + +async function clearAll() { + items = [] + draw() + try { + await post('/api/notifications/clear') + } catch { + // Cleared here; read back as they are when the page is looked at again. + } +} + +/** A card for a notification as it comes, at the foot of the window, gone after a moment. */ +function card(item) { + if (!cards) { + cards = h('div', { class: 'notify-toasts', 'data-hmz': 'notification-toasts', role: 'status', 'aria-live': 'polite' }) + document.body.append(cards) + } + const shut = iconButton('close', 'Dismiss', { class: 'notify-shut' }) + const one = h( + 'div', + { class: 'notify-card', 'data-hmz': 'notification-toast' }, + h('button', { class: 'notify-open', type: 'button', onclick: () => (one.remove(), opens(item)) }, mark(item), h('span', { class: 'choice-text' }, h('span', { class: 'choice-label' }, `${kindOf(item)} · ${daemonOf(item)}`), h('span', { class: 'choice-about' }, about(item)))), + shut, + ) + shut.addEventListener('click', () => one.remove()) + cards.prepend(one) + while (cards.children.length > CARDS) cards.lastChild.remove() + setTimeout(() => one.remove(), SHOWN) +} + +/** One the system shows, while the page is not looked at and the settings let it. */ +async function system(item) { + if (!document.hidden && document.hasFocus()) return + // Read as one comes, rare as they are, so a change in Settings holds at once. + const said = await get('/api/notifications/settings').catch(() => null) + if (!said || !said.system) return + const title = `${kindOf(item)} · ${daemonOf(item)}` + const body = about(item) + const address = addressOf(item) + if (inApp) { + window.__TAURI_INTERNALS__?.invoke('notify', { title, body, address })?.catch?.(() => {}) + return + } + if (!('Notification' in window) || Notification.permission !== 'granted') return + const shown = new Notification(title, { body, tag: String(item.id) }) + shown.onclick = () => { + window.focus() + opens(item) + shown.close() + } +} diff --git a/src/hmz/web/static/paths.js b/src/hmz/web/static/paths.js index ccae0649..2fed069c 100644 --- a/src/hmz/web/static/paths.js +++ b/src/hmz/web/static/paths.js @@ -14,8 +14,9 @@ import { follow } from './shell.js' * @param {string} [made.placeholder] * @param {string} [made.id] * @param {(path: string) => void} [made.changed] Told the path whenever it changes. + * @param {string} [made.from] Whose paths: '' for the daemon the page is on, else `/d/`. */ -export function pathPicker({ value = '', files = false, label, placeholder = files ? '~/file' : '~/folder', id, changed = () => {} }) { +export function pathPicker({ value = '', files = false, label, placeholder = files ? '~/file' : '~/folder', id, changed = () => {}, from = '' }) { const list = `paths-${Math.random().toString(36).slice(2, 8)}` const input = h('input', { type: 'text', value, placeholder, 'aria-label': label, id, list, autocomplete: 'off', spellcheck: 'false', 'data-hmz': 'path-input' }) const offered = h('datalist', { id: list }) @@ -37,7 +38,7 @@ export function pathPicker({ value = '', files = false, label, placeholder = fil clearTimeout(typed) typed = setTimeout(async () => { try { - const { paths } = await get(`/api/paths?typed=${encodeURIComponent(input.value)}`) + const { paths } = await get(`${from}/api/paths?typed=${encodeURIComponent(input.value)}`) fill(offered, paths.map((one) => h('option', { value: one }))) } catch { fill(offered) @@ -48,7 +49,7 @@ export function pathPicker({ value = '', files = false, label, placeholder = fil async function show(dir) { let read try { - read = await get(`/api/files?at=${encodeURIComponent(dir)}${files ? '&files=1' : ''}${hidden ? '&hidden=1' : ''}`) + read = await get(`${from}/api/files?at=${encodeURIComponent(dir)}${files ? '&files=1' : ''}${hidden ? '&hidden=1' : ''}`) } catch (error) { if (dir !== '~') return show('~') fill(panel, h('p', { class: 'error' }, error.message)) diff --git a/src/hmz/web/static/pickers.js b/src/hmz/web/static/pickers.js index c875a906..eb4fc2b0 100644 --- a/src/hmz/web/static/pickers.js +++ b/src/hmz/web/static/pickers.js @@ -3,7 +3,7 @@ // runs as, a model that account runs and an effort that model takes; an environment is a // backend, the runtime of it, and a directory there. Each comes to what `-a` or `-e` spells. -import { get, post } from './api.js' +import { daemon, get, hash, onDaemon, post } from './api.js' import { fill, h, iconButton } from './dom.js' import { pathPicker } from './paths.js' @@ -17,26 +17,41 @@ const failed = new Map() /** Reads the CLIs installed again -- as a workspace runs them -- and tells every picker. */ let reading = '' +/** Whose CLIs they are: '' for the daemon the page is on, else `/d/`, as Settings picks. */ +let base = '' -export async function readBackends(workspace = reading) { +export async function readBackends(workspace = reading, from = '') { reading = workspace - backends = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`) + base = from + const of = daemon() + const read = await get(`${from}/api/backends?workspace=${encodeURIComponent(workspace)}`) + // Read of a daemon the page has since left: another's CLIs. + if (of !== daemon()) return read + backends = read for (const one of told) one() return backends } +// Another daemon's CLIs and accounts are others. +onDaemon(() => { + backends = null + reading = '' + asking.clear() + failed.clear() +}) + /** Asks a CLI what it runs as an account, once however many pickers ask. */ function ask(cli, account) { const key = `${cli}@${account}` if (!asking.has(key)) { asking.set( key, - post('/api/accounts/models', { cli, name: account }) + post(`${base}/api/accounts/models`, { cli, name: account }) .then(() => failed.delete(key)) .catch((error) => failed.set(key, error.message)) .finally(() => { asking.delete(key) - return readBackends() + return readBackends(reading, base) }), ) } @@ -151,7 +166,7 @@ export function agentPicker(role, kept, changed, { effort: withEffort = true } = account.addEventListener('change', () => { if (account.value === '+') { account.value = chosen.account - location.hash = '/settings/accounts' + location.hash = hash('/settings/accounts') return } Object.assign(chosen, { account: account.value, model: '', effort: '' }) @@ -249,7 +264,7 @@ export function envPicker(role, kept, offered, runtimes) { }) machine.addEventListener('change', () => { if (machine.value === '+') { - location.hash = '/settings/runtimes' + location.hash = hash('/settings/runtimes') draw() return } diff --git a/src/hmz/web/static/places.js b/src/hmz/web/static/places.js index fefe8ac5..f25e7521 100644 --- a/src/hmz/web/static/places.js +++ b/src/hmz/web/static/places.js @@ -2,12 +2,14 @@ // opened here, the one `hmz web` was started in first. Picking one is picking where an epic // runs, whose flows are on offer, and whose settings are read. -import { get, post } from './api.js' +import { get, onDaemon, post } from './api.js' import { fill, h, icon } from './dom.js' import { pathPicker } from './paths.js' import { choice, dragged, follow, menu, section } from './shell.js' let known = null +// Another daemon's workspaces are others. +onDaemon(() => (known = null)) /** Every workspace there is to offer, read again where asked to. */ export async function workspaces(again = false) { @@ -97,10 +99,14 @@ export function workspacePicker(chosen, changed) { } }) - workspaces().then((answer) => { - list = answer.workspaces - if (!path) path = answer.here - set(path) - }) + workspaces().then( + (answer) => { + list = answer.workspaces + if (!path) path = answer.here + set(path) + }, + // A daemon that cannot be reached: the view it is on says why. + () => {}, + ) return { node, set, value: () => path } } diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index 479e45a8..27a72c38 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -1,14 +1,17 @@ // The sidebar, as Codex and Claude keep theirs: a new epic, finding one, the flows, and every // workspace of this machine as a folder of its epics, newest first -- each epic opened out into -// its agents, and each agent into the sessions it opened. Under them, the machine, how many -// epics are going on it, and a menu of the settings and what the epics spent. +// its agents, and each agent into the sessions it opened. Under them, the daemon the page is of, +// how many epics are going on it, and a picker of the daemons beside the settings and what the +// epics spent. -import { address, get, poll, post, refresh } from './api.js' +import * as api from './api.js' +import { address, daemon, get, hash, poll, post, refresh, scoped } from './api.js' import { ask } from './held.js' import { pick } from './importing.js' import { copy, menuOn, toast } from './ui.js' import { ago, fill, flowName, h, headline, icon, iconButton, when } from './dom.js' import { sessionsOf } from './epic.js' +import { bell } from './notifications.js' import { nameOf } from './places.js' import { choice, dragged, keep, kept, menu, section, settleSidebar, toggleSidebar } from './shell.js' @@ -26,7 +29,6 @@ const MARKS = { running: '●', stopping: '◐', done: '✔', failed: '✕', sto export function mountSidebar(root, context, where) { const list = h('nav', { class: 'side-runs', 'aria-label': 'Epics', 'data-hmz': 'epics' }, h('p', { class: 'side-quiet', role: 'status' }, 'Reading the epics…')) const more = h('button', { class: 'side-more', type: 'button', hidden: true }, 'Show more') - const state = h('span', { class: 'side-state', 'data-hmz': 'state' }) // How many installed flows have a newer release, as the indexes were fetched as it started. const updates = h('span', { class: 'side-badge', 'data-hmz': 'updates', hidden: true }) poll('/api/flowverses', 60000, (answer) => { @@ -35,44 +37,27 @@ export function mountSidebar(root, context, where) { updates.textContent = String(count) updates.title = count ? `${count} flow${count === 1 ? ' has' : 's have'} a newer release` : '' }) - const account = h( - 'button', - { class: 'side-account', type: 'button', 'data-hmz': 'machine-menu' }, - h('span', { class: 'side-avatar', 'aria-hidden': 'true' }, (context.machine || 'h').slice(0, 1).toUpperCase()), - h('span', { class: 'side-account-text' }, h('b', {}, context.machine || 'This machine'), state), - // A newer release of humanize, as PyPI said when it was last asked. - context.newer ? h('span', { class: 'side-badge', 'data-hmz': 'newer', title: `hmz ${context.newer} is out` }, '↑') : null, - ) - menu( - account, - (close) => [ - context.newer ? choice({ label: 'Update', about: `hmz ${context.newer}`, glyph: 'up', act: () => copy('hmz update', 'Copied hmz update'), close }) : null, - choice({ label: 'Import', glyph: 'file', act: pick, close }), - choice({ label: 'Settings', glyph: 'gear', href: '#/settings/general', close }), - choice({ label: 'Usage', glyph: 'chart', href: '#/settings/usage', close }), - choice({ label: 'Download', glyph: 'download', href: '#/download', close }), - ], - { up: true, label: 'Machine' }, - ) + const bar = daemonBar(context) fill( root, dragged( h( 'div', { class: 'side-top' }, - h('a', { class: 'brand', href: '#/new', 'aria-label': 'humanize, a new epic' }, h('img', { src: '/mark.svg', alt: '', width: '24', height: '24' }), h('strong', {}, 'humanize')), + h('a', { class: 'brand', href: hash('/new'), 'aria-label': 'humanize, a new epic' }, h('img', { src: '/mark.svg', alt: '', width: '24', height: '24' }), h('strong', {}, 'humanize')), + bell(), iconButton('sidebar', 'Hide sidebar', { class: 'side-fold', onclick: () => toggleSidebar(false), 'aria-controls': 'sidebar' }), ), ), h( 'div', { class: 'side-nav' }, - h('a', { href: '#/new', 'data-nav': 'new' }, icon('new'), h('span', {}, 'New epic'), h('kbd', {}, '⇧⌘O')), - h('a', { href: '#/search', 'data-nav': 'search' }, icon('search'), h('span', {}, 'Search epics'), h('kbd', {}, '⌘K')), - h('a', { href: '#/flows', 'data-nav': 'flows' }, icon('flows'), h('span', {}, 'Flows'), updates), + h('a', { href: hash('/new'), 'data-nav': 'new' }, icon('new'), h('span', {}, 'New epic'), h('kbd', {}, '⇧⌘O')), + h('a', { href: hash('/search'), 'data-nav': 'search' }, icon('search'), h('span', {}, 'Search epics'), h('kbd', {}, '⌘K')), + h('a', { href: hash('/flows'), 'data-nav': 'flows' }, icon('flows'), h('span', {}, 'Flows'), updates), ), h('div', { class: 'side-scroll' }, list, more), - h('div', { class: 'side-foot' }, account), + h('div', { class: 'side-foot' }, bar.el), ) // The band across its top, above the row. dragged(root, '') @@ -122,7 +107,7 @@ export function mountSidebar(root, context, where) { else link.removeAttribute('aria-current') } const going = data.runs.filter((one) => one.held).length - fill(state, h('span', { class: ['dot', going && 'live'], 'aria-hidden': 'true' }), going ? `${going} live` : 'idle') + bar.going(going) // Every workspace offered, each with its epics: those going first, then the latest. const groups = new Map() for (const one of places ? places.workspaces : []) groups.set(one.path, { path: one.path, runs: [] }) @@ -149,20 +134,20 @@ export function mountSidebar(root, context, where) { { class: ['side-folder', chosen === group.path && 'current'] }, toggle, h('span', { class: 'side-folder-name', title: group.path }, icon('folder'), h('span', { class: 'side-label' }, nameOf(group.path))), - iconButton('plus', `New epic in ${nameOf(group.path)}`, { class: 'side-add', onclick: () => (location.hash = `/new?workspace=${encodeURIComponent(group.path)}`) }), + iconButton('plus', `New epic in ${nameOf(group.path)}`, { class: 'side-add', onclick: () => (location.hash = hash(`/new?workspace=${encodeURIComponent(group.path)}`)) }), ) const where = encodeURIComponent(group.path) menuOn(folder, () => [ { head: group.path }, - { label: 'New epic here', glyph: 'new', href: `#/new?workspace=${where}` }, - { label: 'Flows here', glyph: 'flows', href: `#/flows?workspace=${where}` }, - { label: 'Search here', glyph: 'search', href: `#/search?workspace=${where}` }, + { label: 'New epic here', glyph: 'new', href: hash(`/new?workspace=${where}`) }, + { label: 'Flows here', glyph: 'flows', href: hash(`/flows?workspace=${where}`) }, + { label: 'Search here', glyph: 'search', href: hash(`/search?workspace=${where}`) }, '-', { label: 'Copy path', glyph: 'copy', act: () => copy(group.path, 'Path copied') }, { label: open ? 'Collapse' : 'Expand', glyph: 'chevron', act: () => toggle.click() }, '-', - { label: 'Settings', glyph: 'gear', href: `#/settings/workspace?workspace=${where}` }, - { label: 'Usage', glyph: 'chart', href: `#/settings/usage?workspace=${where}` }, + { label: 'Settings', glyph: 'gear', href: hash(`/settings/workspace?workspace=${where}`) }, + { label: 'Usage', glyph: 'chart', href: hash(`/settings/usage?workspace=${where}`) }, group.runs.length ? null : { label: 'Forget', danger: true, act: () => forget(group.path) }, ]) return h( @@ -185,7 +170,7 @@ export function mountSidebar(root, context, where) { function item(run, here, parts) { const shown = (opened.has(run.name) || run.name === here) && !folded.has(run.name) - const link = `#/epics/${encodeURIComponent(run.name)}` + const link = hash(`/epics/${encodeURIComponent(run.name)}`) const toggle = h('button', { class: 'side-toggle', type: 'button', 'aria-expanded': String(shown), 'aria-label': shown ? 'Collapse' : 'Expand' }, icon('chevron')) toggle.addEventListener('click', () => { if (shown) { @@ -224,19 +209,21 @@ export function mountSidebar(root, context, where) { const stale = !read || read.how !== run.how || (run.held && Date.now() - read.at > 3000) if (stale && !(read && read.asking)) { details.set(run.name, { ...(read || {}), asking: true }) + const of = daemon() get(`/api/runs/${encodeURIComponent(run.name)}`).then( (detail) => { + if (of !== daemon()) return details.set(run.name, { how: run.how, at: Date.now(), detail }) - draw() + if (data) draw() }, - () => details.set(run.name, { how: run.how, at: Date.now(), detail: null }), + () => of === daemon() && details.set(run.name, { how: run.how, at: Date.now(), detail: null }), ) } const detail = read && read.detail const sessions = detail ? sessionsOf(detail) : [] const agents = run.agents.map((one) => ({ role: one.role, runs: one.runs, sessions: sessions.filter((session) => session.role === one.role) })) if (!agents.length) return h('p', { class: 'side-quiet nested' }, 'No agents') - const base = `#/epics/${encodeURIComponent(run.name)}` + const base = hash(`/epics/${encodeURIComponent(run.name)}`) const logged = run.name === here && parts[2] === 'logs' && !parts[3] const mythed = run.name === here && parts[2] === 'exomyth' return h( @@ -312,12 +299,25 @@ export function mountSidebar(root, context, where) { } watch() - window.addEventListener('hashchange', () => data && draw()) + window.addEventListener('hashchange', () => { + // The links that stay go on to the daemon the address names, as it names it. + for (const link of root.querySelectorAll('.side-top .brand, .side-nav a')) link.setAttribute('href', hash(`/${link.dataset.nav || 'new'}`)) + if (data) draw() + }) + // Another daemon's epics and workspaces: nothing of the last one's is drawn until they are read. + window.addEventListener('hmz:daemon', () => { + data = null + places = null + limit = PAGE + details.clear() + fill(list, h('p', { class: 'side-quiet', role: 'status' }, 'Reading the epics…')) + watch() + }) } /** What can be done to one epic, from wherever it is listed. */ export function epicMenu(run) { - const base = `#/epics/${encodeURIComponent(run.name)}` + const base = hash(`/epics/${encodeURIComponent(run.name)}`) // Going, whether a host holds it or another process does -- `hmz exec`, say. const going = run.held || run.how === 'running' const resumes = run.resumable && !going && run.how !== 'done' @@ -326,13 +326,13 @@ export function epicMenu(run) { { label: 'Open', glyph: 'open', href: base }, { label: 'Open log', glyph: 'thread', href: `${base}/logs` }, { label: 'Exomyth', glyph: 'chart', href: `${base}/exomyth` }, - resumes ? { label: 'Resume', glyph: 'resume', href: `#/new?flow=${encodeURIComponent(run.flow)}&resume=${encodeURIComponent(run.name)}&workspace=${encodeURIComponent(run.workspace)}` } : null, - { label: 'New epic like it', glyph: 'new', href: `#/new?flow=${encodeURIComponent(run.flow)}&workspace=${encodeURIComponent(run.workspace)}` }, + resumes ? { label: 'Resume', glyph: 'resume', href: hash(`/new?flow=${encodeURIComponent(run.flow)}&resume=${encodeURIComponent(run.name)}&workspace=${encodeURIComponent(run.workspace)}`) } : null, + { label: 'New epic like it', glyph: 'new', href: hash(`/new?flow=${encodeURIComponent(run.flow)}&workspace=${encodeURIComponent(run.workspace)}`) }, '-', { label: 'Copy link', glyph: 'link', act: () => copy(`${location.origin}/${base}`, 'Link copied') }, { label: 'Copy task', glyph: 'copy', act: () => copy(run.task, 'Task copied') }, { label: 'Copy name', glyph: 'copy', act: () => copy(run.name, 'Name copied') }, - going ? null : { label: 'Export', glyph: 'download', act: () => (location.href = `/api/runs/${encodeURIComponent(run.name)}/bundle`) }, + going ? null : { label: 'Export', glyph: 'download', act: () => (location.href = scoped(`/api/runs/${encodeURIComponent(run.name)}/bundle`)) }, { label: 'Upload', glyph: 'up', title: backedSaid(run.backed), act: () => upload(run.name) }, run.held ? '-' : null, run.held ? { label: 'Stop', glyph: 'stop', danger: true, act: () => ask(run.workspace, 'stop').then(() => toast('Stopping'), (error) => toast(error.message)) } : null, @@ -351,6 +351,121 @@ export function backedSaid(backed) { .join(' · ') } +/** How a daemon stands, as its dot says it, and the word for it where it is not up. */ +const STATES = { up: 'up', connecting: 'connecting', installing: 'installing', failed: 'failed', idle: 'not connected' } + +/** The daemon the page is of: the address's `@`, or Local without one. */ +function current() { + if (api.daemon) return api.daemon() + let first = location.hash.replace(/^#\/?/, '').split(/[/?]/)[0] + try { + first = decodeURIComponent(first) + } catch { + // Not an address's own escaping: read as it is. + } + return first.startsWith('@') && first.length > 1 ? first.slice(1) : 'local' +} + +/** Moves the page to another daemon, a new epic on it. */ +function switchTo(name) { + if (api.toDaemon) return api.toDaemon(name) + location.hash = `/@${encodeURIComponent(name)}/new` + // Where the page does not tell of a switch itself, it is told here. + if (!api.onDaemon) window.dispatchEvent(new CustomEvent('hmz:daemon', { detail: { name } })) +} + +/** What a daemon is called on the page. */ +function named(name) { + return name === 'local' ? 'Local' : name +} + +/** + * The foot of the sidebar: the daemon the page is of -- its name, its host, how it stands and + * how many epics are going on it -- opening a picker of every daemon and the machine's choices. + */ +function daemonBar(context) { + const local = { name: 'local', host: context.machine || '', state: 'up' } + let daemons = [local] + // How many epics are going, and on which daemon they were counted. + let live = { of: '', count: 0 } + const avatar = h('span', { class: 'side-avatar', 'aria-hidden': 'true' }) + const title = h('b', { 'data-hmz': 'daemon-name' }) + const host = h('span', { class: 'side-host', 'data-hmz': 'daemon-host' }) + const state = h('span', { class: 'side-state', 'data-hmz': 'state' }) + const el = h( + 'button', + { class: 'side-account', type: 'button', 'data-hmz': 'daemon-bar' }, + avatar, + h('span', { class: 'side-account-text' }, h('span', { class: 'side-daemon' }, title, host), state), + // A newer release of humanize, as PyPI said when it was last asked. + context.newer ? h('span', { class: 'side-badge', 'data-hmz': 'newer', title: `hmz ${context.newer} is out` }, '↑') : null, + ) + const entry = () => daemons.find((one) => one.name === current()) || { name: current(), host: '', state: 'idle' } + const said = (one) => (Object.hasOwn(STATES, one.state) ? one.state : 'idle') + const draw = () => { + const one = entry() + const how = said(one) + const up = how === 'up' + avatar.textContent = named(one.name).slice(0, 1).toUpperCase() + title.textContent = named(one.name) + host.textContent = one.host || '' + el.title = [named(one.name), one.host, one.detail].filter(Boolean).join(' · ') + el.dataset.state = how + const going = live.of === one.name ? live.count : 0 + fill(state, h('span', { class: ['dot', `daemon-${how}`, up && going && 'live'], 'aria-hidden': 'true' }), up ? (going ? `${going} live` : 'idle') : STATES[how]) + } + // Every daemon, as the machine serving the page has them; Local alone where it keeps none. + poll('/api/daemons', 10000, (answer, error) => { + const listed = Array.isArray(answer) ? answer : answer && answer.daemons + if (Array.isArray(listed)) daemons = listed.some((one) => one.name === 'local') ? listed : [local, ...listed] + else if (error && error.status === 404) daemons = [local] + draw() + }) + const connect = (name) => { + post(`/api/daemons/${encodeURIComponent(name)}/connect`, {}).then(() => { + toast(`Connecting ${named(name)}`) + refresh('/api/daemons') + }, (error) => toast(error.message)) + } + menu( + el, + (close) => [ + section('Daemons'), + daemons.flatMap((one) => [ + choice({ label: named(one.name), about: [one.host, STATES[said(one)]].filter(Boolean).join(' · '), chosen: one.name === current(), act: () => { + settleSidebar() + if (one.name !== current()) switchTo(one.name) + }, close }), + said(one) === 'failed' ? choice({ label: 'Connect', about: named(one.name), glyph: 'resume', act: () => connect(one.name), close }) : null, + ]), + choice({ label: 'Manage', glyph: 'sliders', href: hash('/settings/daemons'), close }), + section('Machine'), + context.newer ? choice({ label: 'Update', about: `hmz ${context.newer}`, glyph: 'up', act: () => copy('hmz update', 'Copied hmz update'), close }) : null, + choice({ label: 'Import', glyph: 'file', act: pick, close }), + choice({ label: 'Settings', glyph: 'gear', href: hash('/settings/general'), close }), + choice({ label: 'Usage', glyph: 'chart', href: hash('/settings/usage'), close }), + choice({ label: 'Download', glyph: 'download', href: hash('/download'), close }), + ], + { up: true, label: 'Daemon' }, + ) + // The picker is what opened after it, once the menu has put it there. + el.addEventListener('click', () => { + const pop = el.nextElementSibling + if (pop && pop.matches('.popover')) pop.dataset.hmz = 'daemon-picker' + }) + addEventListener('hashchange', draw) + addEventListener('hmz:daemon', draw) + draw() + return { + el, + /** Says how many epics are going on the daemon the page is of. */ + going(count) { + live = { of: current(), count } + draw() + }, + } +} + /** Forgets a workspace nothing ran in, which takes it out of the list. */ function forget(path) { post('/api/workspaces/forget', { workspace: path }).then(() => { diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe47..2f2794c6 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,285 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* daemon bar */ +.side-daemon { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.side-daemon b { + flex: 0 1 auto; + min-width: 3ch; +} + +/* The bar's words take what the row leaves, so a short name is never cut. */ +.side-account .side-account-text { + flex: 1 1 auto; +} + +.side-host { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 11.5px var(--mono); + color: var(--ink-3); +} + +.dot.daemon-connecting, +.dot.daemon-installing { + background: var(--budget); +} + +.dot.daemon-failed { + background: var(--fail); +} + +.dot.daemon-idle { + background: transparent; + box-shadow: inset 0 0 0 1.5px var(--ink-3); +} + +.side-account[data-state='failed'] .side-avatar { + background: var(--fail); +} + +/* settings daemons */ +.settings-group { + margin: 12px 10px 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-3); +} + +.settings-nav h2 + .settings-group { + margin-top: 0; +} + +.settings-daemon { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + border-bottom: 1px solid var(--line); +} + +.settings-daemon select { + min-width: 0; + max-width: 100%; +} + +.batch-dialog { + width: min(calc(100vw - 32px), 420px); + max-height: calc(100vh - 32px); + overflow-y: auto; + padding: 16px 20px; + color: var(--ink); + background: var(--paper); + border: 2px solid var(--ink); + box-shadow: 8px 8px 0 var(--cyan); +} + +.batch-dialog::backdrop { + background: var(--scrim); +} + +.batch-dialog h3 { + margin: 0 0 4px; +} + +.batch-results { + margin: 12px 0; + padding: 0; + list-style: none; + display: grid; + gap: 4px; + overflow-wrap: anywhere; +} + +@media (max-width: 860px) { + .settings-group { + margin: 0 4px 0 8px; + align-self: center; + white-space: nowrap; + } + + .settings-daemon { + padding: 8px 12px; + } +} + +/* settings: daemons, notifications */ +.daemon-state .glyph { + margin-right: 6px; +} + +.daemon-up .glyph { + color: var(--done); +} + +.daemon-failed .glyph { + color: var(--fail); +} + +.daemon-connecting .glyph, +.daemon-installing .glyph { + color: var(--ink-3); +} + +.daemon-detail { + font-size: 12px; + overflow-wrap: anywhere; +} + +.notification-kinds { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.daemons-table td:last-child .row { + flex-wrap: nowrap; + justify-content: flex-end; +} + +/* A phone's width: each daemon a block of its own, its buttons under it. */ +@media (max-width: 860px) { + .daemons-table thead { + display: none; + } + + .daemons-table, + .daemons-table tbody, + .daemons-table tr, + .daemons-table td { + display: block; + } + + .daemons-table tr { + padding: 8px 0; + } + + .daemons-table td { + border: 0; + padding: 2px 0; + } + + .daemons-table td:last-child .row { + flex-wrap: wrap; + justify-content: flex-start; + } +} + +/* notifications */ + +.notify-bell { + position: relative; + margin-left: auto; +} + +.notify-count { + position: absolute; + top: 1px; + right: 0; + min-width: 16px; + padding: 0 4px; + background: var(--pink); + color: var(--on-fill); + font: 700 10px/16px var(--mono); + text-align: center; + pointer-events: none; +} + +.notify-menu { + width: min(340px, calc(100vw - 32px)); +} + +.notify-head { + display: flex; + align-items: center; + gap: 4px; +} + +.notify-head .popover-head { + flex: 1; +} + +.notify-item .glyph, +.notify-open .glyph { + color: var(--tone); +} + +.notify-item.notify-unread .choice-label::after { + content: ''; + display: inline-block; + width: 7px; + height: 7px; + margin-left: 7px; + vertical-align: 1px; + background: var(--cyan); +} + +.notify-when, +.notify-none { + font: 11px var(--mono); + color: var(--ink-3); +} + +.notify-none { + margin: 0; + padding: 10px 8px; +} + +.notify-toasts { + position: fixed; + z-index: 75; + right: 16px; + bottom: 16px; + display: grid; + gap: 8px; + width: min(340px, calc(100vw - 32px)); +} + +.notify-toasts:empty { + display: none; +} + +.notify-card { + display: flex; + align-items: flex-start; + background: var(--raised); + border: 2px solid var(--ink); + box-shadow: 4px 4px 0 var(--pink); + animation: notify-in 0.18s ease-out; +} + +.notify-open { + display: flex; + flex: 1; + min-width: 0; + gap: 8px; + padding: 10px; + border: 0; + background: none; + color: var(--ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.notify-open:hover { + background: var(--hover); +} + +@keyframes notify-in { + from { + transform: translateY(8px); + opacity: 0; + } +} diff --git a/src/hmz/web/static/views/download.js b/src/hmz/web/static/views/download.js index c6c61b6d..1bcef689 100644 --- a/src/hmz/web/static/views/download.js +++ b/src/hmz/web/static/views/download.js @@ -4,7 +4,7 @@ // epics, as an import would, opening it. What the target holds is fetched afresh as it opens, // which can take a while. -import { get, post, refresh } from '../api.js' +import { get, hash, post, refresh } from '../api.js' import { go, shut, stays } from '../app.js' import { ago, badge, empty, fill, flowName, h, headline, icon, iconButton, problem, when } from '../dom.js' import { nameOf } from '../places.js' @@ -22,7 +22,7 @@ export function mount(root, { query }) { get('/api/settings').then((settings) => { const names = settings.backup.targets.map((one) => one.name) if (!names.length) { - fill(body, empty('No backup target.', ' Add one, and the epics it holds are downloaded from here.', h('a', { class: 'btn primary', href: '#/settings/backup' }, 'Backup'))) + fill(body, empty('No backup target.', ' Add one, and the epics it holds are downloaded from here.', h('a', { class: 'btn primary', href: hash('/settings/backup') }, 'Backup'))) return } const chosen = names.includes(query.get('target')) ? query.get('target') : names[0] @@ -79,7 +79,7 @@ export function mount(root, { query }) { function row(target, one) { const action = one.here - ? h('a', { class: 'btn small quiet', href: `#/epics/${encodeURIComponent(one.epic)}` }, 'Open') + ? h('a', { class: 'btn small quiet', href: hash(`/epics/${encodeURIComponent(one.epic)}`) }, 'Open') : h('button', { class: 'btn small', type: 'button', onclick: (event) => pull(target, one, event.currentTarget) }, 'Download') return h( 'div', diff --git a/src/hmz/web/static/views/epic.js b/src/hmz/web/static/views/epic.js index dc80f711..b544c693 100644 --- a/src/hmz/web/static/views/epic.js +++ b/src/hmz/web/static/views/epic.js @@ -9,7 +9,7 @@ // and the side questions asked beside it. import { asides, board, outworlders, question, saying } from '../acting.js' -import { get } from '../api.js' +import { get, hash, scoped } from '../api.js' import { go } from '../app.js' import { acting, ago, badge, flowName, pathText, confirming, copier, empty, fill, h, headline, icon, lasting, problem, secondsOf, when } from '../dom.js' import { ask } from '../held.js' @@ -80,7 +80,7 @@ export function mount(root, { parts, query }) { { class: 'view-pad' }, starting || !none ? h('p', { class: 'loading', role: 'status' }, 'Waiting for the epic to be written down…') - : empty('No epic is going here.', ' Start one, or open an epic from the sidebar.', h('a', { class: 'btn primary', href: '#/new' }, 'New epic')), + : empty('No epic is going here.', ' Start one, or open an epic from the sidebar.', h('a', { class: 'btn primary', href: hash('/new') }, 'New epic')), ), ), ) @@ -91,7 +91,7 @@ export function mount(root, { parts, query }) { if (!name) { // The run going is named now: the address says which, and it is followed by name. name = detail.name - history.replaceState(null, '', `#/epics/${encodeURIComponent(name)}`) + history.replaceState(null, '', hash(`/epics/${encodeURIComponent(name)}`)) landing = chatting(detail) stop() stop = watch(name, detail.workspace, told) @@ -106,7 +106,7 @@ export function mount(root, { parts, query }) { if (opened) { // A chat is a conversation, and is opened on it, as a chat app opens on one. landing = false - history.replaceState(null, '', `#/epics/${encodeURIComponent(name)}/sessions/${encodeURIComponent(opened.key)}`) + history.replaceState(null, '', hash(`/epics/${encodeURIComponent(name)}/sessions/${encodeURIComponent(opened.key)}`)) mode = modeOf(['epics', name, 'sessions', opened.key]) } const model = run ? live(held, run, detail) : written(detail, traced) @@ -129,14 +129,14 @@ export function mount(root, { parts, query }) { const actions = [] if (going && run.state === 'running') actions.push(confirming({ label: 'Stop', ask: 'Stop this epic?', yes: 'Stop', no: 'Cancel', act: () => ask(workspace(), 'stop') })) else if (going && run.state === 'stopping') actions.push(confirming({ label: 'Force stop', ask: 'Close every session now?', yes: 'Force stop', no: 'Cancel', act: () => ask(workspace(), 'force') })) - if (!going && detail.picks_up) actions.push(h('a', { class: 'btn', href: `#/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}` }, icon('resume'), 'Resume')) - actions.push(h('a', { class: 'icon-btn', href: `#/epics/${encodeURIComponent(detail.name)}/exomyth`, 'aria-label': 'Exomyth', title: 'Exomyth', 'data-hmz': 'exomyth-open' }, icon('chart'))) - if (!going) actions.push(h('a', { class: 'icon-btn', href: `/api/runs/${encodeURIComponent(detail.name)}/bundle`, download: '', 'aria-label': 'Export', title: 'Export' }, icon('download'))) + if (!going && detail.picks_up) actions.push(h('a', { class: 'btn', href: hash(`/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}`) }, icon('resume'), 'Resume')) + actions.push(h('a', { class: 'icon-btn', href: hash(`/epics/${encodeURIComponent(detail.name)}/exomyth`), 'aria-label': 'Exomyth', title: 'Exomyth', 'data-hmz': 'exomyth-open' }, icon('chart'))) + if (!going) actions.push(h('a', { class: 'icon-btn', href: scoped(`/api/runs/${encodeURIComponent(detail.name)}/bundle`), download: '', 'aria-label': 'Export', title: 'Export' }, icon('download'))) // How each target last took it, said on hover; read again as the epic is. actions.push(h('button', { class: 'icon-btn', type: 'button', 'aria-label': 'Upload', title: backedSaid(detail.backed) || 'Upload', 'data-hmz': 'upload', onclick: () => upload(detail.name) }, icon('up'))) actions.push(copier('Copy link', () => location.href)) actions.push(panel.toggle) - const base = `#/epics/${encodeURIComponent(detail.name)}` + const base = hash(`/epics/${encodeURIComponent(detail.name)}`) const sub = [ badge(state), h('span', { class: 'mono', title: detail.flow }, flowName(detail.flow)), @@ -348,7 +348,7 @@ export function mount(root, { parts, query }) { dock.querySelector('.composer, .dock-note')?.remove() if (composer) dock.append(composer) else if (detail.picks_up) - dock.append(h('p', { class: 'dock-note' }, 'Ended · ', h('a', { href: `#/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}` }, 'Resume'))) + dock.append(h('p', { class: 'dock-note' }, 'Ended · ', h('a', { href: hash(`/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}`) }, 'Resume'))) // Imported from a workspace this machine has not got as it was: why it is not picked up here. else if (detail.foreign) dock.append(h('p', { class: 'dock-note', 'data-hmz': 'foreign' }, `Imported · not resumable here: ${detail.foreign}`)) }) @@ -403,7 +403,7 @@ export function mount(root, { parts, query }) { function overview(detail, run, model, focus, going, figures, standing) { const agent = model.agents.find((one) => one.role === focus) - const base = `#/epics/${encodeURIComponent(detail.name)}` + const base = hash(`/epics/${encodeURIComponent(detail.name)}`) const parts = [] if (agent) { parts.push( @@ -609,7 +609,7 @@ function placed(env) { /** The agents as a list rather than a graph: the working first, then in the flow's order. */ function listOf(model, epic, focus) { - const base = `#/epics/${encodeURIComponent(epic)}` + const base = hash(`/epics/${encodeURIComponent(epic)}`) const order = [...model.agents].sort((a, b) => Number(b.working) - Number(a.working)) return order.map((agent) => h( @@ -697,7 +697,7 @@ function facts(detail) { h('dd', {}, budgeted(detail.budget)), h('dt', {}, 'sessions'), h('dd', {}, detail.sessions.length ? sessionsOf(detail).map((one) => h('div', {}, h('span', { class: 'mono' }, one.key), ` · ${one.backend}`, one.provider ? `@${one.provider}` : '', h('div', { class: 'dim mono' }, one.ident))) : 'none'), - detail.picked_up ? [h('dt', {}, 'resumed from'), h('dd', {}, h('a', { href: `#/epics/${encodeURIComponent(detail.picked_up)}` }, detail.picked_up))] : null, + detail.picked_up ? [h('dt', {}, 'resumed from'), h('dd', {}, h('a', { href: hash(`/epics/${encodeURIComponent(detail.picked_up)}`) }, detail.picked_up))] : null, detail.profile ? [h('dt', {}, 'profiled'), h('dd', {}, 'yes')] : null, ) } diff --git a/src/hmz/web/static/views/exomyth/view.js b/src/hmz/web/static/views/exomyth/view.js index 6f60f235..7914f02d 100644 --- a/src/hmz/web/static/views/exomyth/view.js +++ b/src/hmz/web/static/views/exomyth/view.js @@ -4,7 +4,7 @@ // what it carried, or a stretch of time. An epic still going is read again every few seconds, // for as long as it is looked at. -import { get, poll, refresh } from '../../api.js' +import { get, hash, poll, refresh } from '../../api.js' import { stays } from '../../app.js' import { badge, copier, fill, h, headline, icon, iconButton, problem } from '../../dom.js' import { bar, sidePanel, tabbed } from '../../shell.js' @@ -53,7 +53,7 @@ export function mount(root, name, query) { // ---------------------------------------------------------------- the bar and the tools function drawHead() { - const link = `#/epics/${encodeURIComponent(name)}` + const link = hash(`/epics/${encodeURIComponent(name)}`) const title = h('span', { class: 'crumbs' }, h('a', { href: link }, detail ? headline(detail.task) : name), h('span', { 'aria-hidden': 'true' }, ' / '), h('span', {}, 'Exomyth')) const sub = model ? [badge(model.live ? 'running' : (detail && detail.how) || 'done'), h('span', {}, `${counted(model.summary.processes)} processes`), h('span', {}, `${counted(model.summary.spans)} spans`), h('span', { class: 'mono' }, lasted(model.end))] diff --git a/src/hmz/web/static/views/flows.js b/src/hmz/web/static/views/flows.js index f661338f..83530ee4 100644 --- a/src/hmz/web/static/views/flows.js +++ b/src/hmz/web/static/views/flows.js @@ -5,7 +5,7 @@ // from, fetched or written into, and -- one opened -- every flow its index lists, every release // of each, installing, switching and uninstalling one; with adding, fetching and removing one. -import { get, poll, post, refresh } from '../api.js' +import { get, hash, poll, post, refresh } from '../api.js' import { stays } from '../app.js' import { acting, confirming, empty, fill, h, icon, permitted, problem } from '../dom.js' import { workspacePicker } from '../places.js' @@ -135,7 +135,7 @@ export function mount(root, { query }) { one.about ? h('span', { class: 'card-about' }, one.about) : null, ) card.addEventListener('click', () => show(one.name)) - const newEpic = `#/new?flow=${encodeURIComponent(one.name)}&workspace=${encodeURIComponent(workspace)}` + const newEpic = hash(`/new?flow=${encodeURIComponent(one.name)}&workspace=${encodeURIComponent(workspace)}`) return menuOn(card, () => [ { head: one.name }, { label: 'New epic', glyph: 'new', href: newEpic }, @@ -172,7 +172,7 @@ export function mount(root, { query }) { if (chosen !== name) return const installed = ((verses && verses.installed) || []).find((one) => one.flow === name) const update = ((verses && verses.updates) || []).find((one) => one.flow === name) - const actions = [h('a', { class: 'btn primary', href: `#/new?flow=${encodeURIComponent(flow.name)}&workspace=${encodeURIComponent(workspace)}` }, icon('new'), 'New epic')] + const actions = [h('a', { class: 'btn primary', href: hash(`/new?flow=${encodeURIComponent(flow.name)}&workspace=${encodeURIComponent(workspace)}`) }, icon('new'), 'New epic')] if (update) actions.push(acting(`Update to ${update.to}`, () => change('/api/flowverses/install', { flow: name, version: update.to }), { busy: 'Updating…' })) if (!name.startsWith('@local/')) actions.push(acting('Copy here', () => change('/api/flows/fork', { flow: name, workspace }), { kind: 'btn quiet', busy: 'Copying…' })) if (installed) actions.push(confirming({ label: 'Uninstall', ask: `Uninstall ${name}?`, yes: 'Uninstall', no: 'Cancel', act: () => change('/api/flowverses/uninstall', { flow: name }) })) diff --git a/src/hmz/web/static/views/import.js b/src/hmz/web/static/views/import.js index 622aafa9..bc71ca29 100644 --- a/src/hmz/web/static/views/import.js +++ b/src/hmz/web/static/views/import.js @@ -4,7 +4,7 @@ // In a browser it is asked first: any page elsewhere can link here, and the page would carry // its key. -import { post, refresh } from '../api.js' +import { hash, post, refresh } from '../api.js' import { fill, h } from '../dom.js' import { refusal } from '../importing.js' import { bar, inApp } from '../shell.js' @@ -21,7 +21,7 @@ export function mount(root, { query }) { if (left) return refresh('/api/runs') // In place of this address, so that going back never imports it again. - location.replace(`#/epics/${encodeURIComponent(answer.name)}/exomyth`) + location.replace(hash(`/epics/${encodeURIComponent(answer.name)}/exomyth`)) }, (error) => { if (!left) fill(body, refusal(error)) @@ -33,7 +33,7 @@ export function mount(root, { query }) { fill( body, h('p', { class: 'path selectable', title: path }, path), - h('p', { class: 'row' }, h('button', { class: 'btn primary', type: 'button', autofocus: true, onclick: go }, 'Import'), h('a', { class: 'btn quiet', href: '#/new' }, 'Cancel')), + h('p', { class: 'row' }, h('button', { class: 'btn primary', type: 'button', autofocus: true, onclick: go }, 'Import'), h('a', { class: 'btn quiet', href: hash('/new') }, 'Cancel')), ) return () => { left = true diff --git a/src/hmz/web/static/views/new.js b/src/hmz/web/static/views/new.js index 0365c099..f6931613 100644 --- a/src/hmz/web/static/views/new.js +++ b/src/hmz/web/static/views/new.js @@ -4,7 +4,7 @@ // Options on the box, set up as this directory last ran that flow, the way `/flow` in the // terminal interface sets one up. Picking a run up again is the same box, on that run. -import { get, post } from '../api.js' +import { get, hash, post } from '../api.js' import { covered, go, stays } from '../app.js' import { composer } from '../composer.js' import { acting, fill, h, icon, permitted, problem, secondsOf } from '../dom.js' @@ -235,7 +235,7 @@ export function mount(root, { query }) { // Back from making an account or a runtime, the pickers offer it. const back = () => { - if (!location.hash.startsWith('#/settings')) readBackends(workspace).catch(() => {}) + if (!location.hash.startsWith(hash('/settings'))) readBackends(workspace).catch(() => {}) } window.addEventListener('hashchange', back) return () => { diff --git a/src/hmz/web/static/views/search.js b/src/hmz/web/static/views/search.js index c9fbca0f..5c6c8440 100644 --- a/src/hmz/web/static/views/search.js +++ b/src/hmz/web/static/views/search.js @@ -3,7 +3,7 @@ // what it spent and how long it took -- found by how it went, by flow and by its task, a page // at a time. The list `/epics` is in the terminal interface. -import { address, poll } from '../api.js' +import { address, hash, poll } from '../api.js' import { shut, stays } from '../app.js' import { nameOf, workspaces } from '../places.js' import { epicMenu } from '../sidebar.js' @@ -108,7 +108,7 @@ export function mount(root, { query }) { {}, menuOn(h( 'a', - { class: ['result', run.held && 'held'], href: `#/epics/${encodeURIComponent(run.name)}`, title: run.task, 'data-hmz': 'epic' }, + { class: ['result', run.held && 'held'], href: hash(`/epics/${encodeURIComponent(run.name)}`), title: run.task, 'data-hmz': 'epic' }, h('span', { class: 'result-top' }, badge(run.how), h('span', { class: 'result-task' }, headline(run.task)), h('span', { class: 'result-when', title: when(run.began) }, ago(run.began))), h( 'span', diff --git a/src/hmz/web/static/views/settings.js b/src/hmz/web/static/views/settings.js index b8480585..cffd0eb0 100644 --- a/src/hmz/web/static/views/settings.js +++ b/src/hmz/web/static/views/settings.js @@ -1,59 +1,236 @@ // What humanize remembers, as ChatGPT keeps its settings: a dialog over the page with its pages -// down the side, in the order the terminal interface's `/settings` lists them -- what is true -// wherever humanize runs, who its agents are and what takes over when one fails, where their -// work goes, where epics are backed up, this one directory -- and last what the runs here spent; and, after what is true -// wherever humanize runs, how this browser draws the page. +// down the side, in two groups. The app's own -- how this page is drawn, which daemons it +// reaches and what it tells you of them -- are this browser's, or this machine's, whichever +// daemon is on screen. The daemon's -- what is true wherever humanize runs, who its agents are +// and what takes over when one fails, where their work goes, where epics are backed up, one +// directory, and last what the runs there spent -- are read from and written to the daemon +// picked above them, the one on screen unless another is, and each page that saves can copy +// what it holds to other daemons at once. -import { shut } from '../app.js' +import * as api from '../api.js' +import { hash } from '../api.js' +import { shut, stays } from '../app.js' import { fill, h, iconButton, problem } from '../dom.js' import { dragged } from '../shell.js' -/** Every page, by the word in the address that opens it. */ +/** Every page, by the word in the address that opens it, in its group. */ const PAGES = { - general: { label: 'General', about: 'Display, side questions and reporting', load: () => import('./settings/general.js') }, - themes: { label: 'Themes', about: 'Colours and pictures of this browser’s page', load: () => import('./settings/themes.js') }, - accounts: { label: 'Accounts', about: 'Credentials agents run as', load: () => import('./settings/accounts.js') }, - fallback: { label: 'Fallback', about: 'Where a failed turn goes next', load: () => import('./settings/fallback.js') }, - runtimes: { label: 'Runtimes', about: 'Machines environments run on', load: () => import('./settings/runtimes.js') }, - backup: { label: 'Backup', about: 'Git repositories epics go to', load: () => import('./settings/backup.js') }, - workspace: { label: 'Workspace', about: 'This directory’s saved setup', load: () => import('./settings/workspace.js') }, - usage: { label: 'Usage', about: 'Spending by day and flow', load: () => import('./settings/usage.js') }, + themes: { group: 'app', label: 'Themes', about: 'Colours and pictures of this browser’s page', load: () => import('./settings/themes.js') }, + daemons: { group: 'app', label: 'Daemons', about: 'Machines this page reaches', load: () => import('./settings/daemons.js') }, + notifications: { group: 'app', label: 'Notifications', about: 'What every daemon tells you', load: () => import('./settings/notifications.js') }, + general: { group: 'daemon', label: 'General', about: 'Display, side questions and reporting', load: () => import('./settings/general.js') }, + accounts: { group: 'daemon', label: 'Accounts', about: 'Credentials agents run as', load: () => import('./settings/accounts.js') }, + fallback: { group: 'daemon', label: 'Fallback', about: 'Where a failed turn goes next', load: () => import('./settings/fallback.js') }, + runtimes: { group: 'daemon', label: 'Runtimes', about: 'Machines environments run on', load: () => import('./settings/runtimes.js') }, + backup: { group: 'daemon', label: 'Backup', about: 'Git repositories epics go to', load: () => import('./settings/backup.js') }, + workspace: { group: 'daemon', label: 'Workspace', about: 'This directory’s saved setup', load: () => import('./settings/workspace.js') }, + usage: { group: 'daemon', label: 'Usage', about: 'Spending by day and flow', load: () => import('./settings/usage.js') }, +} + +const GROUPS = [ + ['app', 'App'], + ['daemon', 'Daemon'], +] + +/** The daemon the page is on, where the page knows of more than one. */ +function current() { + return (typeof api.daemon === 'function' && api.daemon()) || 'local' +} + +/** Where a daemon's API is reached from here: as the page reaches it, or through this server. */ +function prefix(name) { + return name === current() ? '' : `/d/${encodeURIComponent(name)}` +} + +/** What a daemon is called on screen. */ +function called(one) { + return one.label || (one.name === 'local' ? 'Local' : one.name) +} + +/** Every daemon this page reaches: Local alone where this server keeps no list. */ +async function daemons() { + try { + const answer = await api.get('/api/daemons') + const listed = Array.isArray(answer) ? answer : answer.daemons + if (Array.isArray(listed) && listed.length) return listed + } catch { + // An older server, or none of its own: Local is all there is. + } + return [{ name: 'local' }] } export function mount(root, { parts, query }) { const name = parts[1] in PAGES ? parts[1] : 'general' const page = PAGES[name] - const body = h('div', { class: 'settings-body', 'data-hmz': `settings-${name}` }, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + let body = h('div', { class: 'settings-body', 'data-hmz': `settings-${name}` }, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + const links = {} + let chosen = query.get('daemon') || current() + const href = (key) => hash(`/settings/${key}${PAGES[key].group === 'daemon' && query.get('daemon') ? `?daemon=${encodeURIComponent(chosen)}` : ''}`) + const nav = h( + 'nav', + { class: 'settings-nav', 'aria-label': 'Settings pages' }, + h('h2', {}, 'Settings'), + GROUPS.map(([group, label]) => [ + h('h4', { class: 'settings-group' }, label), + Object.entries(PAGES) + .filter(([, one]) => one.group === group) + .map(([key, one]) => (links[key] = h('a', { href: href(key), 'aria-current': key === name ? 'page' : null }, one.label))), + ]), + ) + const picker = h('select', { 'aria-label': 'Daemon', 'data-hmz': 'settings-daemon' }, h('option', { value: chosen }, called({ name: chosen }))) + const head = h('div', { class: 'settings-head' }, h('div', {}, h('h3', {}, page.label), h('p', { class: 'dim' }, page.about)), iconButton('close', 'Close', { onclick: shut })) root.classList.add('dialog-settings') root.setAttribute('aria-label', 'Settings') fill( root, // The top row of a narrow window, which it fills. - dragged( - h( - 'nav', - { class: 'settings-nav', 'aria-label': 'Settings pages' }, - h('h2', {}, 'Settings'), - Object.entries(PAGES).map(([key, one]) => h('a', { href: `#/settings/${key}`, 'aria-current': key === name ? 'page' : null }, one.label)), - ), - ), + dragged(nav), h( 'div', { class: 'settings-page' }, - h('div', { class: 'settings-head' }, h('div', {}, h('h3', {}, page.label), h('p', { class: 'dim' }, page.about)), iconButton('close', 'Close', { onclick: shut })), + head, + page.group === 'daemon' ? h('div', { class: 'settings-daemon' }, h('span', { class: 'label' }, 'Daemon'), picker) : null, body, ), ) + + let known = [{ name: 'local' }] let leaving = null let gone = false - page - .load() - .then((module) => { - if (!gone) leaving = module.draw(body, query, parts) || null + let drawn = 0 + + /** What every page on the daemon picked reads, writes and copies with. */ + const scope = () => { + const base = prefix(chosen) + return { + name: chosen, + base, + at: (path) => `${base}${path}`, + // What a page puts in the address beside its own, so the daemon picked stays in it. + query: query.get('daemon') ? { daemon: chosen } : {}, + batch: (posts, { off = '' } = {}) => batchButton(root, chosen, () => known, posts, off), + } + } + + const draw = () => { + const mine = ++drawn + if (leaving) leaving() + leaving = null + // A body of its own: what the last daemon's page reads late is drawn where nobody sees it. + const fresh = body.cloneNode(false) + body.replaceWith(fresh) + body = fresh + fill(fresh, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + page + .load() + .then((module) => { + if (!gone && mine === drawn) leaving = module.draw(fresh, query, parts, scope()) || null + }) + .catch((error) => fill(fresh, problem(error))) + } + + if (page.group === 'daemon') { + daemons().then((listed) => { + if (gone) return + known = listed + if (!known.some((one) => one.name === chosen)) known = [...known, { name: chosen }] + fill(picker, known.map((one) => h('option', { value: one.name, selected: one.name === chosen }, called(one)))) }) - .catch((error) => fill(body, problem(error))) + picker.addEventListener('change', () => { + chosen = picker.value + // Kept in the address, so that this daemon's page is one that can be kept; what the + // page said of the last daemon's directories says nothing of this one's. + query = new URLSearchParams({ daemon: chosen }) + stays({ daemon: chosen }) + for (const key of Object.keys(PAGES)) links[key].href = href(key) + draw() + }) + } + draw() return () => { gone = true if (leaving) leaving() + for (const one of root.querySelectorAll('dialog')) one.close() } } + +/** + * Copies what a page holds to other daemons: a dialog of every daemon but the one it came from, + * ticked as they are wanted, and what each said once it was sent there. + * + * @param {HTMLElement} root Where the dialog is drawn. + * @param {string} from The daemon the page holds what it holds of. + * @param {() => {name: string}[]} known Every daemon there is, as last read. + * @param {(() => Array<[string, object]>) | null} posts What is sent: each address and body. + * @param {string} off Why nothing can be, where nothing can. + */ +function batchButton(root, from, known, posts, off) { + const button = h('button', { class: 'btn quiet', type: 'button', 'data-hmz': 'batch-apply', disabled: Boolean(off) }, 'Batch apply') + if (off) return h('span', { class: 'row' }, button, h('span', { class: 'hint', 'data-hmz': 'batch-off' }, off)) + button.addEventListener('click', () => { + const others = known().filter((one) => one.name !== from) + const boxes = others.map((one) => ({ one, box: h('input', { type: 'checkbox', 'data-daemon': one.name }) })) + const all = h('input', { type: 'checkbox', 'data-hmz': 'batch-all' }) + all.addEventListener('change', () => boxes.forEach(({ box }) => (box.checked = all.checked))) + for (const { box } of boxes) box.addEventListener('change', () => (all.checked = boxes.every((each) => each.box.checked))) + const results = h('ul', { class: 'batch-results', 'data-hmz': 'batch-results', 'aria-live': 'polite' }) + const apply = h('button', { class: 'btn primary', type: 'button', 'data-hmz': 'batch-go' }, 'Apply') + const dialog = h( + 'dialog', + { class: 'batch-dialog', 'data-hmz': 'batch-dialog', 'aria-label': 'Batch apply' }, + h('h3', {}, 'Batch apply'), + h('p', { class: 'dim' }, `From ${called({ name: from })}`), + others.length + ? h('div', { class: 'form' }, h('label', { class: 'check' }, all, h('b', {}, 'All')), boxes.map(({ one, box }) => h('label', { class: 'check' }, box, called(one)))) + : h('p', { class: 'dim' }, 'No other daemon.'), + results, + h('div', { class: 'row' }, others.length ? apply : null, h('button', { class: 'btn quiet', type: 'button', onclick: () => dialog.close() }, 'Close')), + ) + // Escape closes this dialog, not Settings under it, wherever the focus is. + const escape = (event) => { + if (event.key !== 'Escape') return + event.stopPropagation() + event.preventDefault() + dialog.close() + } + document.addEventListener('keydown', escape, true) + dialog.addEventListener('close', () => { + document.removeEventListener('keydown', escape, true) + dialog.remove() + }) + apply.addEventListener('click', async () => { + const picked = boxes.filter(({ box }) => box.checked).map(({ one }) => one) + if (!picked.length) return + apply.disabled = true + fill(results) + let sent + try { + sent = await posts() + if (!sent.length) throw new Error('Nothing to apply.') + } catch (error) { + fill(results, h('li', {}, problem(error))) + apply.disabled = false + return + } + await Promise.all( + picked.map(async (one) => { + const line = h('li', { 'data-hmz': 'batch-result', 'data-daemon': one.name }, h('b', {}, called(one)), ' ', h('span', { class: 'dim' }, 'Applying…')) + results.append(line) + const base = prefix(one.name) + try { + for (const [path, said] of sent) await api.post(`${base}${path}`, said) + fill(line, h('b', {}, called(one)), ' ', h('span', { class: 'ok' }, 'Applied')) + line.dataset.ok = 'true' + } catch (error) { + fill(line, h('b', {}, called(one)), ' ', h('span', { class: 'bad' }, error.message)) + line.dataset.ok = 'false' + } + }), + ) + apply.disabled = false + }) + root.append(dialog) + dialog.showModal() + }) + return button +} diff --git a/src/hmz/web/static/views/settings/accounts.js b/src/hmz/web/static/views/settings/accounts.js index 87959725..88b82627 100644 --- a/src/hmz/web/static/views/settings/accounts.js +++ b/src/hmz/web/static/views/settings/accounts.js @@ -8,7 +8,7 @@ import { get, post } from '../../api.js' import { acting, ago, confirming, fill, h, problem } from '../../dom.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'accounts' }) const making = h('section', { class: 'panel', 'data-hmz': 'make-account' }) fill( @@ -38,7 +38,7 @@ export function draw(holder) { h( 'div', { class: 'row' }, - acting('Refresh models', async () => shown(await post('/api/accounts/models', { cli: one.cli, name: one.name })), { kind: 'btn small quiet', busy: 'Asking…' }), + acting('Refresh models', async () => shown(await post(scope.at('/api/accounts/models'), { cli: one.cli, name: one.name })), { kind: 'btn small quiet', busy: 'Asking…' }), one.serves.length ? copying(one) : null, one.name ? h('button', { class: 'btn small quiet', type: 'button', onclick: () => correcting(one) }, 'Edit') : null, one.name @@ -47,7 +47,7 @@ export function draw(holder) { ask: `Remove ${one.cli}@${one.name}, credentials and all?`, yes: 'Remove', small: true, - act: async () => shown(await post('/api/accounts/remove', { cli: one.cli, name: one.name })), + act: async () => shown(await post(scope.at('/api/accounts/remove'), { cli: one.cli, name: one.name })), }) : null, ), @@ -56,7 +56,8 @@ export function draw(holder) { ) fill( listing, - h('h2', {}, 'Accounts'), + // Nothing an account holds is ever shown, so nothing of it can be copied elsewhere. + h('header', {}, h('h2', {}, 'Accounts'), scope.batch(null, { off: 'Secrets stay put' })), rows.length ? h( 'div', @@ -74,7 +75,7 @@ export function draw(holder) { 'span', { class: 'row' }, into, - acting('Copy', async () => shown(await post('/api/accounts/copy', { cli: one.cli, name: one.name, into: into.value })), { kind: 'btn small quiet', busy: 'Copying…' }), + acting('Copy', async () => shown(await post(scope.at('/api/accounts/copy'), { cli: one.cli, name: one.name, into: into.value })), { kind: 'btn small quiet', busy: 'Copying…' }), ) } @@ -113,7 +114,7 @@ export function draw(holder) { for (const input of form.querySelectorAll('input[data-env]')) if (input.value) given[input.dataset.env] = input.value save.disabled = true try { - shown(await post('/api/accounts/correct', { cli: one.cli, name: one.name, answers: given })) + shown(await post(scope.at('/api/accounts/correct'), { cli: one.cli, name: one.name, answers: given })) fill(said, h('span', { class: 'dim', role: 'status' }, 'Saved.')) for (const input of form.querySelectorAll('input')) input.value = '' } catch (error) { @@ -203,7 +204,7 @@ export function draw(holder) { for (const input of answers.querySelectorAll('input[data-env]')) if (input.value) given[input.dataset.env] = input.value make.disabled = true try { - shown(await post('/api/accounts', { cli: cli.value, name: name.value.trim(), way: way.value, answers: given })) + shown(await post(scope.at('/api/accounts'), { cli: cli.value, name: name.value.trim(), way: way.value, answers: given })) fill(said, h('span', { class: 'dim', role: 'status' }, `Made ${cli.value}@${name.value.trim()}.`)) name.value = '' for (const input of answers.querySelectorAll('input')) input.value = '' @@ -217,7 +218,7 @@ export function draw(holder) { drawWays() } - get('/api/accounts') + get(scope.at('/api/accounts')) .then((answer) => { shown(answer) drawMaking() diff --git a/src/hmz/web/static/views/settings/backup.js b/src/hmz/web/static/views/settings/backup.js index 1380af93..c118ed94 100644 --- a/src/hmz/web/static/views/settings/backup.js +++ b/src/hmz/web/static/views/settings/backup.js @@ -20,7 +20,7 @@ function whenSaid(one) { return said.length ? said.join(' · ') : 'Manual' } -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'backups' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-backup' }) fill(holder, listing, writing) @@ -35,7 +35,7 @@ export function draw(holder) { /** Writes every target down, as they are with one change made, and lists them again. */ const save = async (targets, defaults) => { - data = (await post('/api/settings', { backup: { targets, defaults } })).backup + data = (await post(scope.at('/api/settings'), { backup: { targets, defaults } })).backup drawListing() } @@ -85,7 +85,7 @@ export function draw(holder) { ) fill( listing, - h('h2', {}, 'Targets'), + h('header', {}, h('h2', {}, 'Targets'), scope.batch(() => [['/api/settings', { backup: data }]])), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Repository'), h('th', {}, 'When'), h('th', {}, ''), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'None yet: no epic is backed up.'), @@ -147,7 +147,7 @@ export function draw(holder) { fill(writing, h('h2', {}, editing ? `Edit ${editing.name}` : 'New target'), form) } - get('/api/settings') + get(scope.at('/api/settings')) .then(shown) .catch((error) => fill(listing, problem(error))) } diff --git a/src/hmz/web/static/views/settings/daemons.js b/src/hmz/web/static/views/settings/daemons.js new file mode 100644 index 00000000..5341280a --- /dev/null +++ b/src/hmz/web/static/views/settings/daemons.js @@ -0,0 +1,267 @@ +// The daemons the page can show: Local, this machine's own, which is always there, and every +// SSH host written down beside it -- each with how far reaching it has got. A new one is written +// a part at a time, as a runtime's SSH host is, or brought in from the hosts an SSH config names; +// one is reached again, changed, or taken away, asked first. + +import { get, post } from '../../api.js' +import { acting, confirming, empty, fill, h, icon, iconButton, problem } from '../../dom.js' +import { pathPicker } from '../../paths.js' + +/** How far reaching each state has got, by a glyph, so nothing is said by colour alone. */ +const STATES = { + up: '●', + connecting: '◐', + installing: '◐', + failed: '✕', + idle: '○', +} + +export function draw(holder) { + const listing = h('section', { class: 'panel', 'data-hmz': 'daemons' }) + const writing = h('section', { class: 'panel', 'data-hmz': 'write-daemon' }) + const importing = h('section', { class: 'panel', 'data-hmz': 'import-daemons' }) + fill(holder, listing, writing, importing) + let daemons = [] + let editing = null + let again = null + let reading = 0 + let gone = false + const states = new Map() + + /** + * Reads the daemons again, and draws them; while one is still being reached, again soon. The + * same daemons as before have only their states drawn again, so a question asked on a row, or + * a refusal said there, stays. + */ + async function read() { + clearTimeout(again) + const mine = ++reading + const answer = await get('/api/daemons') + if (gone || mine !== reading) return + const was = daemons.map((one) => one.name).join('\n') + daemons = (answer.daemons || []).filter((one) => one.name !== 'local') + if (was && was === daemons.map((one) => one.name).join('\n')) for (const one of daemons) fill(states.get(one.name), shownState(stateOf(one))) + else drawListing() + if (daemons.some((one) => ['connecting', 'installing'].includes(stateOf(one).state))) again = setTimeout(() => read().catch(() => {}), 2000) + } + + function drawListing() { + const local = h( + 'tr', + { 'data-hmz': 'daemon', 'data-name': 'local' }, + h('td', {}, h('b', {}, 'Local')), + h('td', { class: 'mono dim' }, 'this machine'), + h('td', {}, shownState({ state: 'up' })), + h('td', {}), + ) + states.clear() + const rows = daemons.map((one) => + h( + 'tr', + { 'data-hmz': 'daemon', 'data-name': one.name }, + h('td', {}, h('b', {}, one.name)), + h('td', { class: 'mono' }, reached(one)), + (states.set(one.name, h('td', {}, shownState(stateOf(one)))), states.get(one.name)), + h( + 'td', + {}, + h( + 'div', + { class: 'row' }, + acting( + 'Connect', + async () => { + await post(`/api/daemons/${encodeURIComponent(one.name)}/connect`) + await read() + }, + { kind: 'btn small quiet', busy: 'Connecting…' }, + ), + h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'), + confirming({ + label: 'Remove', + ask: `Remove ${one.name}?`, + yes: 'Remove', + small: true, + act: async () => { + await post('/api/daemons/remove', { name: one.name }) + if (editing && editing.name === one.name) (editing = null), drawWriting() + await read() + }, + }), + ), + ), + ), + ) + fill( + listing, + h('h2', {}, 'Daemons'), + h('div', { class: 'table-wrap' }, h('table', { class: 'daemons-table' }, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Address'), h('th', {}, 'State'), h('th', {}, ''))), h('tbody', {}, local, rows))), + ) + } + + function edit(one) { + editing = one + drawWriting() + writing.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + function drawWriting() { + const was = editing || {} + const text = (id, value, placeholder, extra = {}) => h('input', { type: 'text', id, value: value || '', placeholder, autocomplete: 'off', spellcheck: 'false', ...extra }) + const name = text('daemon-name', was.name, 'gpu-box', { required: true, disabled: Boolean(editing) }) + const host = text('daemon-host', was.host || was.alias, 'box.example') + const user = text('daemon-user', was.user, 'me') + const port = h('input', { type: 'number', id: 'daemon-port', min: '1', max: '65535', step: '1', value: was.port ? String(was.port) : '', placeholder: '22' }) + const key = pathPicker({ value: was.identity_file || '', files: true, label: 'Identity file', id: 'daemon-identity', placeholder: '~/.ssh/id_ed25519' }) + const jump = text('daemon-jump', was.proxy_jump, 'user@jump') + const options = pairs(was.options || [], 'daemon-options') + const part = (label, id, input, wide = false) => h('div', { class: ['field', wide && 'wide'] }, h('label', { for: id }, label), input) + fill( + writing, + h('h2', {}, editing ? `Edit ${editing.name}` : 'New daemon'), + h( + 'form', + { class: 'form', onsubmit: (event) => event.preventDefault() }, + h( + 'div', + { class: 'grid' }, + part('Name', 'daemon-name', name), + part('Host', 'daemon-host', host), + part('User', 'daemon-user', user), + part('Port', 'daemon-port', port), + part('Identity file', 'daemon-identity', key.node), + part('Proxy jump', 'daemon-jump', jump), + part('SSH options', 'daemon-options', options.node, true), + ), + h( + 'div', + { class: 'row' }, + acting( + editing ? 'Save' : 'Add', + async () => { + const fields = { + host: host.value.trim(), + user: user.value.trim(), + identity_file: key.value(), + proxy_jump: jump.value.trim(), + options: options.read(), + } + if (port.value !== '') fields.port = Number(port.value) + // What an edited daemon was brought in from its ssh config by, kept as it was. + if (editing) for (const kept of ['alias', 'config']) if (editing[kept]) fields[kept] = editing[kept] + const called = name.value.trim() + await post('/api/daemons', { name: called, fields, replace: Boolean(editing) }) + // Renamed: the daemon under its old name goes. + if (editing && editing.name !== called) await post('/api/daemons/remove', { name: editing.name }) + editing = null + drawWriting() + await read() + }, + { kind: 'btn primary', busy: 'Saving…' }, + ), + editing ? h('button', { class: 'btn quiet', type: 'button', onclick: () => ((editing = null), drawWriting()) }, 'New daemon') : null, + ), + ), + ) + } + + function drawImporting() { + const reading = acting( + 'Read SSH config', + async () => { + const hosts = ((await get('/api/daemons/hosts')).hosts || []).map((one) => (typeof one === 'string' ? { alias: one } : one)) + const boxes = hosts.map((one) => ({ one, box: h('input', { type: 'checkbox', 'data-alias': one.alias }) })) + fill( + importing, + h('h2', {}, 'SSH config'), + hosts.length + ? [ + h( + 'div', + { class: 'form' }, + boxes.map(({ one, box }) => h('label', { class: 'check' }, box, h('b', { class: 'mono' }, one.alias), one.host ? h('span', { class: 'dim' }, reached(one)) : null)), + ), + h( + 'p', + { class: 'row' }, + acting( + 'Import', + async () => { + const aliases = boxes.filter(({ box }) => box.checked).map(({ one }) => one.alias) + if (!aliases.length) return + await post('/api/daemons/import', { aliases }) + await read() + drawImporting() + }, + { kind: 'btn primary', busy: 'Importing…' }, + ), + ), + ] + : h('p', { class: 'dim' }, 'Every host it names is added already.'), + ) + }, + { kind: 'btn', busy: 'Reading…' }, + ) + fill(importing, h('h2', {}, 'SSH config'), h('p', { class: 'dim' }, 'Import hosts from your SSH config as daemons.'), reading) + } + + read() + .catch((error) => { + if (error.status === 404) return fill(holder, empty('Unavailable', 'This humanize does not keep daemons.')) + fill(listing, h('h2', {}, 'Daemons'), problem(error)) + }) + .then(() => { + if (holder.contains(writing)) drawWriting(), drawImporting() + }) + return () => { + gone = true + clearTimeout(again) + } +} + +/** A daemon's state, as the list says it: `state` an object, or a word beside `detail`. */ +function stateOf(one) { + if (one.state && typeof one.state === 'object') return one.state + return { state: one.state || 'idle', detail: one.detail || '', version: one.version || '' } +} + +function shownState({ state, detail = '', version = '' }) { + return h( + 'div', + { class: ['daemon-state', `daemon-${state}`], 'data-hmz': 'daemon-state' }, + h('span', { class: 'glyph', 'aria-hidden': 'true' }, STATES[state] || '·'), + h('span', {}, state), + version ? h('span', { class: 'dim' }, ` ${version}`) : null, + detail ? h('div', { class: 'dim daemon-detail' }, detail) : null, + ) +} + +/** Where a daemon is reached, as somebody would write it. */ +function reached(one) { + return `${one.user ? `${one.user}@` : ''}${one.host || one.alias || one.name}${one.port && one.port !== 22 ? `:${one.port}` : ''}` +} + +/** SSH options, a name and its value a row, in order and any name more than once: one more added, any taken away. */ +function pairs(values, id) { + const rows = h('div', { class: 'list-rows', id, 'data-hmz': 'pair-editor' }) + const add = h('button', { class: 'btn small quiet', type: 'button' }, icon('plus'), 'Add') + const row = (key, value) => { + const line = h( + 'div', + { class: 'list-row' }, + h('input', { type: 'text', value: key, placeholder: 'Name', 'aria-label': 'Option', class: 'pair-key' }), + h('input', { type: 'text', value, placeholder: 'Value', 'aria-label': 'Value', class: 'pair-value' }), + iconButton('close', 'Remove', { class: 'small', onclick: () => line.remove() }), + ) + rows.append(line) + } + for (const [key, value] of values) row(key, String(value)) + add.addEventListener('click', () => { + row('', '') + rows.lastElementChild.querySelector('input').focus() + }) + return { + node: h('div', { class: 'list-editor' }, rows, add), + read: () => [...rows.children].map((line) => [line.querySelector('.pair-key').value.trim(), line.querySelector('.pair-value').value.trim()]).filter(([key]) => key), + } +} diff --git a/src/hmz/web/static/views/settings/fallback.js b/src/hmz/web/static/views/settings/fallback.js index ded517ce..88218a2b 100644 --- a/src/hmz/web/static/views/settings/fallback.js +++ b/src/hmz/web/static/views/settings/fallback.js @@ -6,7 +6,7 @@ import { get, post } from '../../api.js' import { acting, confirming, fill, h, iconButton, lasting, problem } from '../../dom.js' import { placePicker, readBackends } from '../../pickers.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'fallbacks' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-fallback' }) const places = h('datalist', { id: 'places' }) @@ -36,14 +36,15 @@ export function draw(holder) { 'div', { class: 'row' }, h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'), - confirming({ label: 'Clear', ask: `Let ${one.spec} fall back nowhere again?`, yes: 'Clear', small: true, act: async () => shown(await post('/api/fallbacks/clear', { spec: one.spec })) }), + confirming({ label: 'Clear', ask: `Let ${one.spec} fall back nowhere again?`, yes: 'Clear', small: true, act: async () => shown(await post(scope.at('/api/fallbacks/clear'), { spec: one.spec })) }), ), ), ), ) fill( listing, - h('h2', {}, 'Steps'), + // Each step written over the one of its place there, or added; none there taken away. + h('header', {}, h('h2', {}, 'Steps'), scope.batch(() => data.steps.map(({ spec, to, tries, policy, timeout }) => ['/api/fallbacks', { spec, to, tries, policy, timeout }]))), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Place'), h('th', {}, 'Tried'), h('th', {}, 'Then'), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'No steps. A failed turn fails where it ran.'), @@ -103,7 +104,7 @@ export function draw(holder) { 'Save', async () => { const to = [...chain.children].map((line) => line.picked.value()).filter(Boolean) - const answer = await post('/api/fallbacks', { spec: spec.value(), to, tries: Number(tries.value || 0), policy: policy.value, timeout: Number(timeout.value || 0) }) + const answer = await post(scope.at('/api/fallbacks'), { spec: spec.value(), to, tries: Number(tries.value || 0), policy: policy.value, timeout: Number(timeout.value || 0) }) editing = null shown(answer) }, @@ -118,7 +119,7 @@ export function draw(holder) { } let pickers = [] - Promise.all([get('/api/fallbacks'), readBackends()]) + Promise.all([get(scope.at('/api/fallbacks')), readBackends(undefined, scope.base)]) .then(([answer]) => shown(answer)) .catch((error) => fill(listing, problem(error))) } diff --git a/src/hmz/web/static/views/settings/general.js b/src/hmz/web/static/views/settings/general.js index 0f24f520..4d062288 100644 --- a/src/hmz/web/static/views/settings/general.js +++ b/src/hmz/web/static/views/settings/general.js @@ -5,7 +5,7 @@ import { get, post } from '../../api.js' import { acting, fill, h, problem } from '../../dom.js' import { agentPicker, readBackends } from '../../pickers.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const shown = (settings) => { const details = h('input', { type: 'checkbox', checked: settings.details }) // Who a side question goes to: the flow's first agent, or one picked as an agent is picked. @@ -21,16 +21,13 @@ export function draw(holder) { h('option', { value: 'yes', selected: settings.reports === true }, 'On'), h('option', { value: 'no', selected: settings.reports === false }, 'Off'), ) - const save = acting( - 'Save', - async () => { - const said = { btw: whom.value === 'chosen' ? picker.value() : '', details: details.checked } - if (reports.value) said.reports = reports.value === 'yes' - // Saved where it stands: the button says so, rather than the form being drawn again over it. - await post('/api/settings', said) - }, - { kind: 'btn primary', busy: 'Saving…', done: 'Saved' }, - ) + const said = () => { + const given = { btw: whom.value === 'chosen' ? picker.value() : '', details: details.checked } + if (reports.value) given.reports = reports.value === 'yes' + return given + } + // Saved where it stands: the button says so, rather than the form being drawn again over it. + const save = acting('Save', () => post(scope.at('/api/settings'), said()), { kind: 'btn primary', busy: 'Saving…', done: 'Saved' }) fill( holder, h( @@ -42,14 +39,14 @@ export function draw(holder) { h('div', { class: 'field' }, h('span', { class: 'label' }, 'Details'), h('label', { class: 'check' }, details, 'Tool calls and thinking')), h('div', { class: 'field', 'data-hmz': 'btw-agent' }, h('span', { class: 'label' }, 'btw agent'), whom, picker.node, h('span', { class: 'hint' }, 'Who a side question about a whole epic is asked of.')), h('div', { class: 'field' }, h('span', { class: 'label' }, 'Error reports'), reports, h('span', { class: 'hint' }, 'Whether humanize sends a report of its own crashes. Nothing anybody typed, no file and no credential is ever sent.')), - h('div', { class: 'row' }, save), + h('div', { class: 'row' }, save, scope.batch(() => [['/api/settings', said()]])), ), ), ) } let picker = null let backends = null - Promise.all([get('/api/settings'), readBackends()]) + Promise.all([get(scope.at('/api/settings')), readBackends(undefined, scope.base)]) .then(([settings, read]) => { backends = read shown(settings) diff --git a/src/hmz/web/static/views/settings/notifications.js b/src/hmz/web/static/views/settings/notifications.js new file mode 100644 index 00000000..58fed786 --- /dev/null +++ b/src/hmz/web/static/views/settings/notifications.js @@ -0,0 +1,109 @@ +// Which notifications may appear, of every daemon at once: an epic done, failed or stopped, a +// question to you, a chat turn over -- and whether they are shown by the system as well as on +// the page. This app's own, kept on this machine, never a daemon's. A browser shows them only +// once you allow it, from a click here; the desktop app needs nothing more. + +import { get, post } from '../../api.js' +import { empty, fill, h, problem } from '../../dom.js' +import { inApp } from '../../shell.js' + +/** Every kind, by its word in the settings, and what the page calls it. */ +const KINDS = [ + ['done', 'Done'], + ['failed', 'Failed'], + ['stopped', 'Stopped'], + ['asked', 'Questions'], + ['listening', 'Chat turns'], +] + +/** What the browser says of showing notifications, as the page says it. */ +const PERMISSIONS = { granted: 'Allowed', denied: 'Blocked', default: 'Not asked' } + +export function draw(holder) { + const shown = (settings) => { + const kinds = { ...Object.fromEntries(KINDS.map(([kind]) => [kind, true])), ...(settings.kinds || {}) } + let system = settings.system !== false + const said = h('span', { class: 'hint', 'aria-live': 'polite', 'data-hmz': 'notifications-said' }) + const save = async () => { + said.replaceChildren() + try { + await post('/api/notifications/settings', { kinds, system }) + said.textContent = 'Saved' + } catch (error) { + said.replaceChildren(problem(error)) + } + } + const toggle = (label, checked, changed, hook) => { + const box = h('input', { type: 'checkbox', checked, 'data-hmz': hook }) + box.addEventListener('change', () => { + changed(box.checked) + save() + }) + return h('label', { class: 'check' }, box, label) + } + const allowing = h('div', { class: 'row', 'data-hmz': 'notifications-permission' }) + const drawAllowing = () => { + if (inApp) return fill(allowing) + if (!('Notification' in window)) return fill(allowing, h('span', { class: 'dim' }, 'Unsupported')) + const now = Notification.permission + fill( + allowing, + h('span', { class: 'dim', 'data-hmz': 'permission-state' }, PERMISSIONS[now] || now), + now === 'granted' || now === 'denied' + ? null + : h( + 'button', + { + class: 'btn small', + type: 'button', + 'data-hmz': 'allow-notifications', + onclick: async () => { + try { + // A promise where it is one; older Safari answers by calling back instead. + await Notification.requestPermission(drawAllowing) + } finally { + drawAllowing() + } + }, + }, + 'Allow', + ), + ) + } + drawAllowing() + fill( + holder, + h( + 'section', + { class: 'panel notification-settings' }, + h( + 'div', + { class: 'form' }, + h( + 'div', + { class: 'field' }, + h('span', { class: 'label' }, 'Kinds'), + h( + 'div', + { class: 'notification-kinds' }, + KINDS.map(([kind, label]) => toggle(label, kinds[kind], (on) => (kinds[kind] = on), `notify-${kind}`)), + ), + h('span', { class: 'hint' }, 'Of every daemon, as they happen.'), + ), + h( + 'div', + { class: 'field' }, + h('span', { class: 'label' }, 'System'), + toggle('On', system, (on) => (system = on), 'notify-system'), + allowing, + h('span', { class: 'hint' }, inApp ? 'Shown by macOS while the window is away.' : 'Shown by the browser while the page is away.'), + ), + said, + ), + ), + ) + } + get('/api/notifications/settings') + .then(shown) + .catch((error) => fill(holder, error.status === 404 ? empty('Unavailable', 'This humanize does not keep notifications.') : problem(error))) +} diff --git a/src/hmz/web/static/views/settings/runtimes.js b/src/hmz/web/static/views/settings/runtimes.js index a27ad2f4..bbdd4fb7 100644 --- a/src/hmz/web/static/views/settings/runtimes.js +++ b/src/hmz/web/static/views/settings/runtimes.js @@ -3,13 +3,13 @@ // reaching it asks; a new one is written from its backend's own fields, or brought in from // the hosts an ssh config names. -import { get, post } from '../../api.js' +import { get, onDaemon, post } from '../../api.js' import { acting, confirming, fill, h, icon, iconButton, problem } from '../../dom.js' import { pathPicker } from '../../paths.js' const GIB = 2 ** 30 -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'runtimes' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-runtime' }) const importing = h('section', { class: 'panel', 'data-hmz': 'import-runtimes' }) @@ -43,20 +43,21 @@ export function draw(holder) { acting( 'Check', async () => { - checked[key] = await post('/api/runtimes/check', { backend: one.backend, name: one.name }) + checked[key] = await post(scope.at('/api/runtimes/check'), { backend: one.backend, name: one.name }) drawListing() }, { kind: 'btn small quiet', busy: 'Asking…' }, ), h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'), - confirming({ label: 'Remove', ask: `Remove ${one.name}?`, yes: 'Remove', small: true, act: async () => shown(await post('/api/runtimes/remove', { backend: one.backend, name: one.name })) }), + confirming({ label: 'Remove', ask: `Remove ${one.name}?`, yes: 'Remove', small: true, act: async () => shown(await post(scope.at('/api/runtimes/remove'), { backend: one.backend, name: one.name })) }), ), ), ) }) fill( listing, - h('h2', {}, 'Runtimes'), + // Each runtime written over the one of its name there, or added; none there taken away. + h('header', {}, h('h2', {}, 'Runtimes'), scope.batch(() => data.runtimes.map(({ backend, name, made, ...fields }) => ['/api/runtimes', { backend, name, fields, replace: true }]))), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Backend'), h('th', {}, 'Name'), h('th', {}, 'Address'), h('th', {}, 'Resources'), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'None yet: every environment is this machine, or spelled out where it is named.'), @@ -79,7 +80,7 @@ export function draw(holder) { fill(fields) for (const [key, empty] of Object.entries(blank)) { const value = editing ? editing[key] : empty - const [input, read] = field(key, empty, value, { backend: backend.value, name: name.value.trim(), runtimes: data.runtimes }) + const [input, read] = field(key, empty, value, { backend: backend.value, name: name.value.trim(), runtimes: data.runtimes, from: scope.base }) readers[key] = read fields.append(h('div', { class: ['field', (Array.isArray(empty) || (empty && typeof empty === 'object')) && 'wide'] }, h('label', { for: `runtime-${key}` }, labelOf(key)), input)) } @@ -103,7 +104,7 @@ export function draw(holder) { async () => { const given = {} for (const [key, read] of Object.entries(readers)) given[key] = read() - const answer = await post('/api/runtimes', { backend: backend.value, name: name.value.trim(), fields: given, replace: Boolean(editing) }) + const answer = await post(scope.at('/api/runtimes'), { backend: backend.value, name: name.value.trim(), fields: given, replace: Boolean(editing) }) editing = null shown(answer) }, @@ -121,7 +122,7 @@ export function draw(holder) { const read = acting( 'Read SSH config', async () => { - const { hosts } = await get('/api/runtimes/hosts') + const { hosts } = await get(scope.at('/api/runtimes/hosts')) const boxes = hosts.map((one) => ({ one, box: h('input', { type: 'checkbox', disabled: one.saved }) })) fill( importing, @@ -143,7 +144,7 @@ export function draw(holder) { async () => { const names = boxes.filter(({ box }) => box.checked).map(({ one }) => one.alias) if (!names.length) return - shown(await post('/api/runtimes/import', { names })) + shown(await post(scope.at('/api/runtimes/import'), { names })) drawImporting() }, { kind: 'btn primary', busy: 'Importing…' }, @@ -158,7 +159,7 @@ export function draw(holder) { fill(importing, h('h2', {}, 'SSH config'), h('p', { class: 'dim' }, 'Import hosts from your SSH config as runtimes.'), read) } - get('/api/runtimes') + get(scope.at('/api/runtimes')) .then((answer) => { shown(answer) drawImporting() @@ -194,11 +195,12 @@ function labelOf(key) { return `${words.slice(0, 1).toUpperCase()}${words.slice(1)}${key.endsWith('memory') ? ', GiB' : ''}` } -/** The SSH hosts your config names, read once, for the fields that name one. */ -let hosts = null -function sshHosts() { - hosts ||= get('/api/runtimes/hosts').then((answer) => answer.hosts.map((one) => one.alias), () => []) - return hosts +/** The SSH hosts each daemon's config names, read once, for the fields that name one. */ +const hosts = new Map() +onDaemon(() => hosts.clear()) +function sshHosts(from) { + if (!hosts.has(from)) hosts.set(from, get(`${from}/api/runtimes/hosts`).then((answer) => answer.hosts.map((one) => one.alias), () => [])) + return hosts.get(from) } /** @@ -207,10 +209,10 @@ function sshHosts() { * a directory browsed for, the runtimes to fall back to and where a harness runs picked from * what is saved and put in order, options as names and values, and a list a row at a time. */ -function field(key, empty, value, { backend, name, runtimes }) { +function field(key, empty, value, { backend, name, runtimes, from }) { const id = `runtime-${key}` if (key === 'identity_file' || key === 'config' || key === 'tls_dir') { - const picked = pathPicker({ value: value || '', files: key !== 'tls_dir', label: labelOf(key), id, placeholder: key === 'config' ? '~/.ssh/config' : key === 'tls_dir' ? '~/.docker' : '~/.ssh/id_ed25519' }) + const picked = pathPicker({ value: value || '', files: key !== 'tls_dir', label: labelOf(key), id, from, placeholder: key === 'config' ? '~/.ssh/config' : key === 'tls_dir' ? '~/.docker' : '~/.ssh/id_ed25519' }) return [picked.node, () => picked.value()] } if (key === 'fallback') { @@ -244,7 +246,7 @@ function field(key, empty, value, { backend, name, runtimes }) { // A host your SSH config names, picked; any other written. const offered = h('datalist', { id: `${id}-hosts` }) const input = h('input', { id, type: 'text', value: value || '', list: offered.id, autocomplete: 'off', placeholder: key === 'alias' ? 'A host in your SSH config' : key === 'host' ? 'box.example' : 'user@jump' }) - sshHosts().then((names) => fill(offered, names.map((one) => h('option', { value: one })))) + sshHosts(from).then((names) => fill(offered, names.map((one) => h('option', { value: one })))) return [h('div', { class: 'row' }, input, offered), () => input.value.trim()] } const input = h('input', { id, type: 'text', value: value || '', autocomplete: 'off' }) diff --git a/src/hmz/web/static/views/settings/themes.js b/src/hmz/web/static/views/settings/themes.js index 30cb807b..87dba5c1 100644 --- a/src/hmz/web/static/views/settings/themes.js +++ b/src/hmz/web/static/views/settings/themes.js @@ -4,6 +4,7 @@ // it is made on, each of its colours and a picture behind any of the large planes, shown on the // page as it is changed and kept at once. +import { hash } from '../../api.js' import { confirming, fill, h, iconButton, problem } from '../../dom.js' import { choice, menu } from '../../shell.js' import { menuOn, toast } from '../../ui.js' @@ -61,11 +62,11 @@ function list(holder) { }) const fresh = async (from, name) => { const one = await look.create(from, name) - location.hash = `/settings/themes/${one.id}` + location.hash = hash(`/settings/themes/${one.id}`) } const freshPair = async ([light, dark], name) => { const [one] = await look.createPair(light, dark, name) - location.hash = `/settings/themes/${one.id}` + location.hash = hash(`/settings/themes/${one.id}`) } const use = (one) => { look.choose(one.id) @@ -75,7 +76,7 @@ function list(holder) { const offered = (one) => [ { head: look.label(one) }, { label: 'Use', glyph: 'check', act: () => use(one) }, - look.OWN.includes(one) ? null : { label: 'Edit', glyph: 'new', href: `#/settings/themes/${one.id}` }, + look.OWN.includes(one) ? null : { label: 'Edit', glyph: 'new', href: hash(`/settings/themes/${one.id}`) }, { label: 'Duplicate', glyph: 'copy', act: () => fresh(one, `${one.name} copy`) }, { label: 'Export', glyph: 'download', act: () => look.exported(one) }, ] @@ -107,7 +108,7 @@ function list(holder) { 'div', { class: 'row' }, h('button', { class: 'btn small', type: 'button', disabled: on, onclick: () => (look.usePair(pair.key), redraw()) }, 'Use pair'), - pair.mine ? either('Edit', two, (one) => (location.hash = `/settings/themes/${one.id}`)) : null, + pair.mine ? either('Edit', two, (one) => (location.hash = hash(`/settings/themes/${one.id}`))) : null, either('Duplicate', two, (one) => fresh(one, `${pair.name} ${one.base}`), () => freshPair(two, `${pair.name} copy`)), either('Export', two, (one) => look.exported(one), () => look.exported(pair.key)), pair.mine ? confirming({ label: 'Delete', ask: `Delete ${pair.name}?`, yes: 'Delete', small: true, act: () => look.removePair(pair.key).then(redraw) }) : null, @@ -126,7 +127,7 @@ function list(holder) { 'div', { class: 'row' }, h('button', { class: 'btn small', type: 'button', disabled: on, onclick: () => use(one) }, 'Use'), - h('a', { class: 'btn quiet small', href: `#/settings/themes/${one.id}` }, 'Edit'), + h('a', { class: 'btn quiet small', href: hash(`/settings/themes/${one.id}`) }, 'Edit'), h('button', { class: 'btn quiet small', type: 'button', onclick: () => fresh(one, `${one.name} copy`) }, 'Duplicate'), h('button', { class: 'btn quiet small', type: 'button', onclick: () => look.exported(one) }, 'Export'), confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', small: true, act: () => look.remove(one.id).then(redraw) }), @@ -209,7 +210,7 @@ function hex(colour) { function editor(holder, id) { const one = look.theme(id) if (!one || look.OWN.includes(one)) { - fill(holder, problem('No such theme'), h('p', { class: 'row' }, h('a', { class: 'btn', href: '#/settings/themes' }, 'Themes'))) + fill(holder, problem('No such theme'), h('p', { class: 'row' }, h('a', { class: 'btn', href: hash('/settings/themes') }, 'Themes'))) return } // What the theme's colours are where it sets none: its base's, as a theme on it works them out. @@ -233,7 +234,7 @@ function editor(holder, id) { 'div', { class: 'segmented', role: 'group', 'aria-label': 'Side', 'data-hmz': 'theme-side' }, look.members(one.pair).map((other) => - h('button', { type: 'button', 'aria-pressed': String(other.id === one.id), onclick: () => (location.hash = `/settings/themes/${other.id}`) }, other.base === 'dark' ? 'Dark' : 'Light'), + h('button', { type: 'button', 'aria-pressed': String(other.id === one.id), onclick: () => (location.hash = hash(`/settings/themes/${other.id}`)) }, other.base === 'dark' ? 'Dark' : 'Light'), ), ) : null @@ -350,12 +351,12 @@ function editor(holder, id) { h( 'div', { class: 'row' }, - h('a', { class: 'btn', href: '#/settings/themes' }, 'Done'), + h('a', { class: 'btn', href: hash('/settings/themes') }, 'Done'), one.pair ? h('button', { class: 'btn quiet', type: 'button', onclick: () => (look.usePair(one.pair), toast(`Using ${one.name}`)) }, 'Use pair') : h('button', { class: 'btn quiet', type: 'button', onclick: () => (look.choose(one.id), toast(`Using ${one.name}`)) }, 'Use'), h('button', { class: 'btn quiet', type: 'button', onclick: () => look.exported(one.pair || one) }, 'Export'), - confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', act: () => (one.pair ? look.removePair(one.pair) : look.remove(one.id)).then(() => (location.hash = '/settings/themes')) }), + confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', act: () => (one.pair ? look.removePair(one.pair) : look.remove(one.id)).then(() => (location.hash = hash('/settings/themes'))) }), ), ), ), diff --git a/src/hmz/web/static/views/settings/usage.js b/src/hmz/web/static/views/settings/usage.js index dfa5001a..699e05b2 100644 --- a/src/hmz/web/static/views/settings/usage.js +++ b/src/hmz/web/static/views/settings/usage.js @@ -2,19 +2,18 @@ // chart is drawn in plain HTML with a table beside it, so no figure rests on a colour or on a // pointer hovering over it. -import { address, poll } from '../../api.js' +import { address, get, hash, poll } from '../../api.js' import { stays } from '../../app.js' import { badge, fill, flowName, h, problem } from '../../dom.js' -import { workspaces } from '../../places.js' /** How far back the page can look, in days. */ const RANGES = [7, 30, 90, 365] -export function draw(holder, query) { +export function draw(holder, query, parts, scope) { let days = Number(query.get('days')) || 30 let workspace = query.get('workspace') || '' const places = h('select', { 'aria-label': 'Workdir' }, h('option', { value: '' }, 'All workdirs')) - workspaces().then((answer) => { + get(scope.at('/api/workspaces')).then((answer) => { fill(places, h('option', { value: '' }, 'All workdirs'), answer.workspaces.map((one) => h('option', { value: one.path, selected: one.path === workspace, title: one.path }, one.name))) }, () => {}) places.addEventListener('change', () => { @@ -28,7 +27,7 @@ export function draw(holder, query) { let stop = () => {} const watch = () => { stop() - stays({ days, workspace }) + stays({ ...scope.query, days, workspace }) fill( range, RANGES.map((one) => { @@ -40,7 +39,7 @@ export function draw(holder, query) { return button }), ) - stop = poll(address('/api/usage', { days, workspace }), 30000, (data, error) => draw(data, error)) + stop = poll(address(scope.at('/api/usage'), { days, workspace }), 30000, (data, error) => draw(data, error)) } function draw(data, error) { @@ -121,7 +120,7 @@ export function draw(holder, query) { h( 'tr', {}, - h('td', {}, h('a', { href: `#/search?flow=${encodeURIComponent(one.flow)}`, title: one.flow }, flowName(one.flow))), + h('td', {}, h('a', { href: hash(`/search?flow=${encodeURIComponent(one.flow)}`), title: one.flow }, flowName(one.flow))), h('td', { class: 'num' }, one.runs), h('td', { class: 'num' }, one.money), h('td', {}, h('div', { class: 'share', title: share(one.cost, whole.cost) }, h('span', { style: { width: share(one.cost, whole.cost) } }))), diff --git a/src/hmz/web/static/views/settings/workspace.js b/src/hmz/web/static/views/settings/workspace.js index 6a5a8ac5..c9ff19fe 100644 --- a/src/hmz/web/static/views/settings/workspace.js +++ b/src/hmz/web/static/views/settings/workspace.js @@ -1,11 +1,10 @@ // What a workspace was last set up with: the flow it opens on and every flow set up there, and // forgetting all of it -- which also stops it being offered, where no epic ran there. -import { get, post } from '../../api.js' +import { address, get, post } from '../../api.js' import { confirming, fill, h, pathText, problem } from '../../dom.js' -import { workspaces } from '../../places.js' -export function draw(holder, query) { +export function draw(holder, query, parts, scope) { let workspace = query.get('workspace') || '' const choosing = h('select', { 'aria-label': 'Workdir', 'data-hmz': 'settings-workdir' }) const body = h('div') @@ -41,7 +40,7 @@ export function draw(holder, query) { yes: 'Forget', no: 'Cancel', act: async () => { - const answer = await post('/api/settings/forget', { workspace: settings.workspace }) + const answer = await post(scope.at('/api/settings/forget'), { workspace: settings.workspace }) shown(answer, answer.forgot) }, }), @@ -51,12 +50,12 @@ export function draw(holder, query) { } function read() { - get(`/api/settings?workspace=${encodeURIComponent(workspace)}`) + get(address(scope.at('/api/settings'), { workspace })) .then((settings) => shown(settings)) .catch((error) => fill(body, problem(error))) } - workspaces().then((answer) => { + get(scope.at('/api/workspaces')).then((answer) => { if (!workspace) workspace = answer.here fill(choosing, answer.workspaces.map((one) => h('option', { value: one.path, selected: one.path === workspace }, one.path))) read() diff --git a/tests/integration/doubles_ssh.py b/tests/integration/doubles_ssh.py new file mode 100644 index 00000000..a620212e --- /dev/null +++ b/tests/integration/doubles_ssh.py @@ -0,0 +1,218 @@ +"""A stand-in for `ssh`, and the machine it reaches: this one, under a home of its own. + +The `ssh` put first on `PATH` takes what the real one is told -- its options, the destination, +the command -- drops the options and the destination, and runs the command here, with `sh`, in +the other machine's home: its own `HOME`, `HUMANIZE_HOME` and `TMPDIR`. A port it is told to +forward with `-L` it forwards over TCP, for as long as it runs. A destination with `unreachable` +in it is one it cannot reach, as `ssh` says so. Every time it is run it writes its pid and what +it was told to `ssh.log` beside that home. + +The machine has an `hmz` in `~/.local/bin` which is this one, saying whichever version it is +said to be installed at, or none, and a `uv` whose `tool install` installs the version asked. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +if TYPE_CHECKING: + import pytest + +_SSH = """#!{python} +import json, os, socket, subprocess, sys, threading + +HOME, HUMANIZE, TMP, LOG = {home!r}, {humanize!r}, {tmp!r}, {log!r} +TAKES = set("BbcDEeFIiJLlmOopQRSWw") + +args, forwards, at = sys.argv[1:], [], 0 +while at < len(args) and args[at].startswith("-"): + flag = args[at][1:2] + if flag in TAKES: + value = args[at][2:] or args[at + 1] + at += 1 if args[at][2:] else 2 + if flag == "L": + forwards.append(value) + else: + at += 1 +host, command = args[at], " ".join(args[at + 1:]) +with open(LOG, "a") as log: + log.write(json.dumps([os.getpid(), *sys.argv[1:]]) + "\\n") +if "unreachable" in host: + print(f"ssh: Could not resolve hostname {{host}}: Name or service not known", + file=sys.stderr) + sys.exit(255) + + +def pumps(source, sink): + try: + while chunk := source.recv(65536): + sink.sendall(chunk) + except OSError: + pass + for one in (source, sink): + try: + one.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + +def serves(listening, to): + while True: + near, _ = listening.accept() + try: + far = socket.create_connection(to) + except OSError: + near.close() + continue + for there in ((near, far), (far, near)): + threading.Thread(target=pumps, args=there, daemon=True).start() + + +for spec in forwards: + *bind, port, to, to_port = spec.split(":") + listening = socket.socket() + listening.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + listening.bind((bind[0] if bind else "127.0.0.1", int(port))) + except OSError as why: + print(f"bind: {{why}}", file=sys.stderr) + sys.exit(255) + listening.listen() + threading.Thread( + target=serves, args=(listening, (to, int(to_port))), daemon=True + ).start() + +env = dict(os.environ, HOME=HOME, HUMANIZE_HOME=HUMANIZE, TMPDIR=TMP) +os.chdir(HOME) +if not command: + threading.Event().wait() +if not forwards: + os.execvpe("sh", ["sh", "-c", command], env) +sys.exit(subprocess.run(["sh", "-c", command], env=env).returncode) +""" + +_HMZ = """#!/bin/sh +if [ ! -f "$HOME/.hmz-version" ]; then echo "sh: hmz: not found" >&2; exit 127; fi +if [ "$1" = "--version" ]; then echo "hmz $(cat "$HOME/.hmz-version")"; exit 0; fi +# What its interface says on stderr is also kept, a stack dumped on SIGABRT among it. +if [ "$1" = "web" ]; then exec {python} -X faulthandler -m hmz "$@" 2>>"$HOME/web.err"; fi +exec {python} -m hmz "$@" +""" + +_UV = """#!/bin/sh +echo "$@" >> "$HOME/uv.log" +for said; do case "$said" in hmz==*) version="${said#hmz==}";; esac; done +echo "Resolved 1 package in 3ms" +printf '%s' "$version" > "$HOME/.hmz-version" +echo "Installed 1 executable: hmz" +""" + + +@dataclass(frozen=True) +class Host: + """The machine the stand-in reaches. + + Attributes: + home: Its `HOME`. + humanize: Its `HUMANIZE_HOME`. + tmp: Its `TMPDIR`. + log: Where each `ssh` run writes its pid and what it was told. + """ + + home: Path + humanize: Path + tmp: Path + log: Path + + def installs(self, version: str | None) -> None: + """Has its `hmz` say this version, or be missing.""" + held = self.home / ".hmz-version" + if version is None: + held.unlink(missing_ok=True) + else: + held.write_text(version) + + def runs_epics(self) -> None: + """Has its daemon running, which holds its epics: this process, as one alive.""" + mine = self.tmp / f"humanize-{os.getuid()}" + mine.mkdir(mode=0o700, exist_ok=True) + (mine / "daemon.json").write_text(f'{{\n "pid": {os.getpid()}\n}}\n') + + def installed(self) -> str: + """What `uv` was asked to install, one line a time.""" + held = self.home / "uv.log" + return held.read_text() if held.exists() else "" + + def pids(self) -> list[int]: + """Every `ssh` run so far, by pid.""" + if not self.log.exists(): + return [] + return [json.loads(line)[0] for line in self.log.read_text().splitlines()] + + def told(self) -> list[list[str]]: + """What every `ssh` run so far was told.""" + if not self.log.exists(): + return [] + return [json.loads(line)[1:] for line in self.log.read_text().splitlines()] + + +def faked( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, version: str | None = "0.0.0" +) -> Host: + """Puts the stand-in `ssh` first on `PATH`, reaching a machine of its own. + + Args: + tmp_path: The test's own directory. + monkeypatch: What puts `PATH` back. + version: What its `hmz` says it is, or None for none at all. + + Returns: + The machine. + """ + there = tmp_path / "there" + host = Host( + there / "home", + there / "humanize", + # Short, as a socket's path under it must be. + Path(tempfile.mkdtemp(prefix="r", dir=os.environ["TMPDIR"])), + there / "ssh.log", + ) + (host.home / ".local" / "bin").mkdir(parents=True) + host.humanize.mkdir() + made = tmp_path / "bin" + made.mkdir() + for at, script in ( + ( + made / "ssh", + _SSH.format( + python=sys.executable, + home=str(host.home), + humanize=str(host.humanize), + tmp=str(host.tmp), + log=str(host.log), + ), + ), + (host.home / ".local" / "bin" / "hmz", _HMZ.format(python=sys.executable)), + (host.home / ".local" / "bin" / "uv", _UV), + ): + at.write_text(script) + at.chmod(0o755) + monkeypatch.setenv("PATH", f"{made}{os.pathsep}{os.environ['PATH']}") + host.installs(version) + return host + + +def written(*daemons: dict[str, object]) -> None: + """Writes these daemons down in this machine's settings, as `daemons`.""" + from hmz.coganchor import settings + + settings.changes(lambda held: held.__setitem__("daemons", list(daemons))) + assert yaml.safe_load(settings.where().read_text())["daemons"] diff --git a/tests/integration/test_web_daemons.py b/tests/integration/test_web_daemons.py new file mode 100644 index 00000000..e71b55ce --- /dev/null +++ b/tests/integration/test_web_daemons.py @@ -0,0 +1,124 @@ +"""`hmz web`'s daemons: written down, brought in from the ssh config, and removed.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from hmz import home +from tests.integration.doubles_web import Browser, served + +pytestmark = pytest.mark.skipif(shutil.which("ssh") is None, reason="needs ssh -G") + + +@pytest.fixture +def ssh_config() -> Path: + """An ssh config, in the test's own home, naming two hosts.""" + at = Path.home() / ".ssh" / "config" + at.parent.mkdir(parents=True, exist_ok=True) + at.write_text( + "Host gpu\n HostName gpu.example\n User me\n Port 2222\n" + "Host box\n HostName box.example\n" + ) + return at + + +def _settings() -> dict[str, Any]: + return yaml.safe_load((home() / "settings.yaml").read_text()) + + +def test_a_daemon_is_added_edited_and_removed(ssh_config: Path) -> None: + with served() as site: + browser = Browser(site) + browser.signs_in() + + assert [ + one["label"] for one in browser.get("/api/daemons").json()["daemons"] + ] == ["Local"] + added = browser.post( + "/api/daemons", + { + "name": "lab", + "fields": {"host": "lab.example", "options": [["Compression", "yes"]]}, + }, + ) + assert added.status == 200, added.body + assert [(one["name"], one["state"]) for one in added.json()["daemons"]] == [ + ("local", "up"), + ("lab", "idle"), + ] + assert ( + browser.post( + "/api/daemons", {"name": "lab", "fields": {"host": "b"}} + ).status + == 409 + ) + assert ( + browser.post( + "/api/daemons", + {"name": "lab", "fields": {"host": "b"}, "replace": True}, + ).status + == 200 + ) + refused = browser.post( + "/api/daemons", {"name": "local", "fields": {"host": "b"}} + ) + assert refused.status == 400 + assert refused.json()["error"] + assert browser.post("/api/daemons", {"name": "x", "nope": 1}).status == 400 + + assert _settings()["daemons"] == [ + { + "name": "lab", + "host": "b", + "user": "", + "port": 0, + "identity_file": "", + "proxy_jump": "", + "alias": "", + "config": "", + "options": [], + } + ] + + removed = browser.post("/api/daemons/remove", {"name": "lab"}) + assert [one["name"] for one in removed.json()["daemons"]] == ["local"] + assert browser.post("/api/daemons/remove", {"name": "local"}).status == 400 + + +def test_hosts_of_the_ssh_config_are_offered_and_imported(ssh_config: Path) -> None: + with served() as site: + browser = Browser(site) + browser.signs_in() + + hosts = browser.get("/api/daemons/hosts").json()["hosts"] + # Which hosts are named is read off the config in this home; what each comes to + # is `ssh -G`'s, which reads the config of the account's own home instead. + assert [(one["alias"], one["name"]) for one in hosts] == [ + ("gpu", "gpu"), + ("box", "box"), + ] + imported = browser.post("/api/daemons/import", {"aliases": ["gpu"]}) + assert imported.json()["added"] == ["gpu"] + again = browser.post("/api/daemons/import", {"aliases": ["gpu", "box"]}) + assert again.json()["added"] == ["box"] + assert [one["name"] for one in again.json()["daemons"]] == [ + "local", + "gpu", + "box", + ] + assert browser.get("/api/daemons/hosts").json()["hosts"] == [] + assert ( + browser.post("/api/daemons/import", {"aliases": ["elsewhere"]}).status + == 404 + ) + assert [one["alias"] for one in _settings()["daemons"]] == ["gpu", "box"] + + +def test_a_stranger_is_told_nothing_of_the_daemons(ssh_config: Path) -> None: + with served() as site: + assert Browser(site).get("/api/daemons").status == 401 diff --git a/tests/integration/test_web_daemons_together.py b/tests/integration/test_web_daemons_together.py new file mode 100644 index 00000000..e8ffdcd7 --- /dev/null +++ b/tests/integration/test_web_daemons_together.py @@ -0,0 +1,75 @@ +"""Another machine's daemon, reached over ssh, telling this page of its epics. + +A chat started on the other machine through this interface waits on its next turn there; the +notice that says so comes back as a notification here, said to be that daemon's, and so does +its stopping. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import pytest + +from tests.integration.doubles_core import AGENT, install +from tests.integration.doubles_ssh import faked, written +from tests.integration.doubles_web import PATIENCE, Browser, served + +if TYPE_CHECKING: + from pathlib import Path + + from tests.integration.doubles_ssh import Host + + +@pytest.fixture +def host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Host: + """The stand-in `claude` on PATH, and the other machine, written down as `box`.""" + (tmp_path / "here").mkdir() + install(tmp_path / "here", monkeypatch) + found = faked(tmp_path, monkeypatch) + written({"name": "box", "host": "box.example", "user": "me"}) + return found + + +def _told(browser: Browser, kind: str) -> dict[str, Any]: + """The first notification of that kind, once there is one.""" + deadline = time.monotonic() + PATIENCE + while time.monotonic() < deadline: + items = browser.get("/api/notifications").json()["items"] + found = [one for one in items if one["kind"] == kind] + if found: + return found[0] + time.sleep(0.2) + raise AssertionError(f"no {kind} notification in {PATIENCE}s") + + +def test_a_chat_on_another_machine_is_told_here(host: Host) -> None: + there = host.home / "project" + there.mkdir() + with served() as site: + browser = Browser(site) + browser.signs_in() + listed = browser.get("/api/daemons").json()["daemons"] + assert [one["name"] for one in listed] == ["local", "box"] + # Reached, which starts its interface and what follows its notices. + assert browser.get("/d/box/api/held").status == 200 + started = browser.post( + f"/d/box/api/held/start?workspace={there}", + {"flow": "chat", "task": "hello there", "agents": {"assistant": AGENT}}, + ) + assert started.status == 200, started.body + + listening = _told(browser, "listening") + assert listening["daemon"] == "box" + assert listening["workspace"] == str(there) + assert listening["task"] == "hello there" + assert not listening["read"] + + read = browser.post("/api/notifications/read", {"all": True}).json() + assert read["unread"] == 0 + assert ( + browser.post("/d/box/api/held/stop", {"workspace": str(there)}).status + == 200 + ) + assert _told(browser, "stopped")["daemon"] == "box" diff --git a/tests/integration/test_web_notices.py b/tests/integration/test_web_notices.py new file mode 100644 index 00000000..53e65459 --- /dev/null +++ b/tests/integration/test_web_notices.py @@ -0,0 +1,150 @@ +"""`hmz web`: an epic ending, and a chat waiting on its next turn, said as notices.""" + +from __future__ import annotations + +import contextlib +import http.client +import json +import time +from typing import TYPE_CHECKING, Any + +import pytest + +from tests.integration.doubles_core import AGENT, install, write_flow +from tests.integration.doubles_web import PATIENCE, Browser, served + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + from pathlib import Path + +#: A flow of one turn of the stand-in, which ends done. +_ONCE = ''' +from hmz.flows import ( + Agent, AgentCollection, EnvCollection, FlowContext, FlowParams, LocalEnv, flow, +) + + +class Agents(AgentCollection): + """The one.""" + + solo: Agent + + +class Envs(EnvCollection): + """Where it works.""" + + workspace: LocalEnv + + +@flow(agents=Agents, envs=Envs, params=FlowParams) +async def once( + task: str, *, agents: Agents, envs: Envs, params: FlowParams, ctx: FlowContext +) -> str: + """One turn, and done.""" + one = agents["solo"] + return await one.run(task, session=await one.spawn(), env=envs["workspace"]) +''' + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """The stand-in `claude` alone on PATH, and a project to serve the runs of.""" + return install(tmp_path, monkeypatch) + + +@contextlib.contextmanager +def _follows( + browser: Browser, last: str = "" +) -> Generator[Callable[[], tuple[str, dict[str, Any]]]]: + """Listens to the notices' stream as a page does: each notice, by its id.""" + connection = http.client.HTTPConnection( + "127.0.0.1", browser.site.port, timeout=PATIENCE + ) + headers = {"Host": f"127.0.0.1:{browser.site.port}", "Cookie": browser.cookie} + if last: + headers["Last-Event-ID"] = last + connection.request("GET", "/api/notices/stream", headers=headers) + stream = connection.getresponse() + + def next_notice() -> tuple[str, dict[str, Any]]: + ident = event = "" + while True: + line = stream.readline().decode().rstrip("\n") + if line.startswith("id: "): + ident = line[len("id: ") :] + elif line.startswith("event: "): + event = line[len("event: ") :] + elif line.startswith("data: ") and event == "notice": + return ident, json.loads(line[len("data: ") :]) + + try: + yield next_notice + finally: + connection.close() + + +def _until( + next_notice: Callable[[], tuple[str, dict[str, Any]]], kind: str +) -> tuple[str, dict[str, Any]]: + ending = time.monotonic() + PATIENCE + while time.monotonic() < ending: + ident, notice = next_notice() + if notice["kind"] == kind: + return ident, notice + raise AssertionError(f"no {kind} notice came") + + +def test_a_chat_waiting_and_an_epic_ending_are_notices(project: Path) -> None: + write_flow(project / ".hmz" / "flows", "once", _ONCE) + with served() as site: + browser = Browser(site) + browser.signs_in() + assert browser.get("/api/notices").json() == {"notices": [], "last": 0} + + with _follows(browser) as heard: + chat = { + "flow": "chat", + "task": "hello there", + "agents": {"assistant": AGENT}, + } + assert browser.post("/api/held/start", chat).status == 200 + _, listening = _until(heard, "listening") + assert (listening["workspace"], listening["flow"]) == (str(project), "chat") + assert (listening["task"], listening["role"]) == ("hello there", "human") + assert listening["epic"] + + assert browser.post("/api/held/stop").status == 200 + stopped_at, stopped = _until(heard, "stopped") + assert stopped["epic"] == listening["epic"] + + started = browser.post( + "/api/held/start", + { + "flow": "@local/once", + "task": "do it", + "agents": {"solo": AGENT}, + "budget": {"cost": 5}, + }, + ) + assert started.status == 200, started.json() + _, done = _until(heard, "done") + assert (done["flow"], done["task"], done["text"]) == ( + "@local/once", + "do it", + "", + ) + assert done["epic"] != listening["epic"] + + kept = browser.get("/api/notices").json() + assert [one["kind"] for one in kept["notices"]] == [ + "listening", + "stopped", + "done", + ] + assert kept["last"] == kept["notices"][-1]["id"] + after = browser.get(f"/api/notices?after={stopped_at}").json()["notices"] + assert [one["kind"] for one in after] == ["done"] + assert browser.get("/api/notices?after=soon").status == 400 + # Carried on from the last one heard, a stream hears what came after it. + with _follows(browser, stopped_at) as heard: + assert heard()[1]["kind"] == "done" diff --git a/tests/integration/test_web_notifications.py b/tests/integration/test_web_notifications.py new file mode 100644 index 00000000..a1f86cb7 --- /dev/null +++ b/tests/integration/test_web_notifications.py @@ -0,0 +1,267 @@ +"""The interface's notifications: this machine's notices and an SSH daemon's, told as one. + +The interface is served in this process; this machine's notices are a stand-in told what to +say, and an SSH daemon is a stand-in for its interface, reached on a port of this machine as +a forwarded one is: it lets in what comes with its key, says where its notices stand, and +streams what it says after that. +""" + +from __future__ import annotations + +import contextlib +import http.client +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import TYPE_CHECKING, Any, cast + +import yaml + +from hmz import home +from tests.integration.doubles_web import PATIENCE, Browser, served + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + + from hmz.web.site import Site + + +class _Notices: + """This machine's notices: what hears them, told each as it is said.""" + + def __init__(self) -> None: + self.hearing: list[Callable[[Any], object]] = [] + + def heard(self, callback: Callable[[Any], object]) -> None: + self.hearing.append(callback) + + def since(self, after: str) -> list[dict[str, Any]]: + return [] + + def says(self, notice: dict[str, Any]) -> None: + for one in self.hearing: + one(notice) + + +class _Daemon(ThreadingHTTPServer): + """An SSH daemon's interface, as its forwarded port reaches it.""" + + daemon_threads = True + + def __init__(self) -> None: + super().__init__(("127.0.0.1", 0), _Answers) + #: What its stream says next, a notice at a time. + self.saying: list[dict[str, Any]] = [] + #: The `Last-Event-ID` each stream was asked from. + self.asked: list[str] = [] + + +class _Answers(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + @property + def _daemon(self) -> _Daemon: + return cast("_Daemon", self.server) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + pass + + def do_GET(self) -> None: + if self.path == "/?key=sesame": + self.send_response(303) + self.send_header("Set-Cookie", "hmz-4242=sesame; HttpOnly; Path=/") + self.send_header("Content-Length", "0") + self.end_headers() + return + if "hmz-4242=sesame" not in self.headers.get("Cookie", ""): + self.send_error(401) + return + if self.path == "/api/notices": + body = json.dumps({"notices": [], "last": "5"}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + self._daemon.asked.append(self.headers.get("Last-Event-ID", "")) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + self.close_connection = True + # Until the interface following it lets it go. + with contextlib.suppress(OSError): + self._says() + + def _says(self) -> None: + while True: + while self._daemon.saying: + notice = self._daemon.saying.pop(0) + said = ( + f"id: {notice['id']}\nevent: notice\ndata: {json.dumps(notice)}\n\n" + ) + self.wfile.write(said.encode()) + self.wfile.write(b": quiet\n\n") + self.wfile.flush() + time.sleep(0.05) + + +class _Remotes: + """The SSH daemons reached: `gpu`, up on the stand-in's port.""" + + def __init__(self, daemon: _Daemon) -> None: + self._daemon = daemon + + def state(self, name: str) -> dict[str, Any]: + return {"state": "up" if name == "gpu" else "idle", "detail": "", "version": ""} + + def reached(self, name: str) -> tuple[int, str]: + return int(self._daemon.server_address[1]), "sesame" + + +@contextlib.contextmanager +def _told() -> Generator[tuple[Site, Browser, _Notices, _Daemon]]: + home().mkdir(parents=True, exist_ok=True) + (home() / "settings.yaml").write_text( + yaml.safe_dump({"daemons": [{"name": "gpu", "host": "gpu.example"}]}) + ) + daemon = _Daemon() + threading.Thread(target=daemon.serve_forever, daemon=True).start() + try: + with served() as site: + notices = _Notices() + site.notices = notices # pyright: ignore[reportAttributeAccessIssue] + site.remotes = _Remotes(daemon) # pyright: ignore[reportAttributeAccessIssue] + browser = Browser(site) + browser.signs_in() + _waits(lambda: bool(notices.hearing) and bool(daemon.asked), "followed") + yield site, browser, notices, daemon + finally: + daemon.shutdown() + daemon.server_close() + + +def _waits(done: Callable[[], bool], what: str) -> None: + ending = time.monotonic() + PATIENCE + while not done(): + assert time.monotonic() < ending, f"never {what}" + time.sleep(0.05) + + +def _items(browser: Browser) -> list[dict[str, Any]]: + answer = browser.get("/api/notifications") + assert answer.status == 200, answer.body + return answer.json()["items"] + + +def _notice(kind: str, epic: str, ident: str = "1") -> dict[str, Any]: + return {"id": ident, "kind": kind, "workspace": "/w", "epic": epic, "text": ""} + + +def test_every_daemons_notices_are_told_as_one_by_who_said_them() -> None: + with _told() as (_, browser, notices, daemon): + notices.says(_notice("done", "here")) + daemon.saying.append(_notice("asked", "there", "6")) + _waits(lambda: len(_items(browser)) == 2, "told both") + + items = _items(browser) + + assert [(one["daemon"], one["epic"], one["kind"]) for one in items] == [ + ("gpu", "there", "asked"), + ("local", "here", "done"), + ] + # What the daemon said before it was first reached is not told. + assert daemon.asked == ["5"] + + +def test_a_kind_turned_off_is_never_told_and_written_down_as_off() -> None: + with _told() as (_, browser, notices, _): + answer = browser.post( + "/api/notifications/settings", {"kinds": {"listening": False}} + ) + + assert answer.status == 200, answer.body + assert answer.json() == browser.get("/api/notifications/settings").json() + written = yaml.safe_load((home() / "settings.yaml").read_text()) + assert written["notifications"]["kinds"]["listening"] is False + assert written["notifications"]["system"] is True + assert written["daemons"] == [{"name": "gpu", "host": "gpu.example"}] + notices.says(_notice("listening", "chat")) + notices.says(_notice("failed", "broke")) + assert [one["kind"] for one in _items(browser)] == ["failed"] + + +def test_settings_that_are_not_notifications_are_refused() -> None: + with _told() as (_, browser, _, _): + for wrong in ( + {"kinds": {"x": True}}, + {"kinds": {"done": "no"}}, + {"kinds": False}, + {"system": "yes"}, + {"sound": True}, + ): + answer = browser.post("/api/notifications/settings", wrong) + assert answer.status == 400, wrong + assert "notifications" not in (home() / "settings.yaml").read_text() + + +def test_notifications_are_read_and_cleared() -> None: + with _told() as (_, browser, notices, _): + notices.says(_notice("done", "a")) + notices.says(_notice("stopped", "b")) + first = _items(browser)[-1]["id"] + + answer = browser.post("/api/notifications/read", {"ids": [first]}) + assert answer.json()["unread"] == 1 + assert ( + browser.post("/api/notifications/read", {"all": True}).json()["unread"] == 0 + ) + assert browser.post("/api/notifications/clear").json() == { + "items": [], + "unread": 0, + } + + +def test_the_stream_tells_each_as_it_comes_and_takes_up_where_it_was_left() -> None: + with _told() as (site, browser, notices, _): + notices.says(_notice("done", "before")) + with _following(site, browser, "") as next_event: + notices.says(_notice("asked", "after")) + ident, event, data = next_event() + assert (event, data["epic"], data["daemon"], data["read"]) == ( + "notification", + "after", + "local", + False, + ) + with _following(site, browser, "1") as next_event: + assert next_event()[0] == ident + + +@contextlib.contextmanager +def _following( + site: Site, browser: Browser, last: str +) -> Generator[Callable[[], tuple[str, str, Any]]]: + connection = http.client.HTTPConnection("127.0.0.1", site.port, timeout=PATIENCE) + headers = {"Cookie": browser.cookie} | ({"Last-Event-ID": last} if last else {}) + connection.request("GET", "/api/notifications/stream", headers=headers) + stream = connection.getresponse() + assert stream.status == 200 + + def next_event() -> tuple[str, str, Any]: + ident = event = data = "" + while True: + line = stream.readline().decode().rstrip("\n") + if line.startswith("id: "): + ident = line[4:] + elif line.startswith("event: "): + event = line[7:] + elif line.startswith("data: "): + data = line[6:] + elif not line and event: + return ident, event, json.loads(data) + + try: + yield next_event + finally: + connection.close() diff --git a/tests/integration/test_web_remote.py b/tests/integration/test_web_remote.py new file mode 100644 index 00000000..7afa4b76 --- /dev/null +++ b/tests/integration/test_web_remote.py @@ -0,0 +1,302 @@ +"""A daemon on another machine, reached over ssh and answered through this interface. + +The other machine is this one under a home of its own, reached by the stand-in `ssh` of +`doubles_ssh`, running this `hmz web` there as the real one would run the one installed. +""" + +from __future__ import annotations + +import contextlib +import http.client +import os +import re +import signal +import socket +import subprocess +import time +from typing import TYPE_CHECKING, Any + +import pytest + +from hmz.web.remote import Remotes + +from .doubles_ssh import Host, faked, written +from .doubles_web import PATIENCE, Answer, Browser, served + +if TYPE_CHECKING: + from pathlib import Path + + from hmz.web.site import Site + + +@pytest.fixture +def host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Host: + """The other machine, written down as `box`, with this version of `hmz`.""" + found = faked(tmp_path, monkeypatch) + _THERE[:] = [found.home] + written( + { + "name": "box", + "host": "box.example", + "user": "me", + "options": [["Compression", "yes"]], + } + ) + return found + + +#: The other machine's home, for what its interface said when it never came up. +_THERE: list[Path] = [] + + +def _processes() -> str: + """What runs of the stand-in's, and the stack of each interface there that never listened.""" + listed = subprocess.run( + ["ps", "-axo", "pid,ppid,etime,command"], + capture_output=True, + text=True, + check=False, + ).stdout + mine = [line for line in listed.splitlines() if re.search(r"ssh|hmz|\bsh\b", line)] + for line in mine: + pid, ppid, *_ = line.split() + if int(ppid) == os.getpid() and "faulthandler -m hmz web" in line: + with contextlib.suppress(OSError): + os.kill(int(pid), signal.SIGABRT) + time.sleep(1) + said = [ + (home / "web.err").read_text() for home in _THERE if (home / "web.err").exists() + ] + return "\n".join([*mine, *said]) + + +def _stands(remotes: Remotes, name: str, *states: str) -> dict[str, str]: + """How a daemon stands, once it stands as one of `states`.""" + ending = time.monotonic() + PATIENCE + while (said := remotes.state(name))["state"] not in states: + assert time.monotonic() < ending, ( + f"{name} never came to {states}: {said}\n{_processes()}" + ) + time.sleep(0.05) + return said + + +def _asked(browser: Browser, path: str) -> Answer: + """What the interface answers a read with, once the daemon is reached.""" + ending = time.monotonic() + PATIENCE + while (answer := browser.get(path)).status == 503: + assert time.monotonic() < ending, f"{answer.body!r}\n{_processes()}" + time.sleep(0.1) + return answer + + +def _gone(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + return False + + +def test_a_page_reads_and_writes_a_daemon_on_another_machine(host: Host) -> None: + with served() as site: + browser = Browser(site) + browser.signs_in() + listed = _asked(browser, "/d/box/api/workspaces") + assert listed.status == 200, listed.body + assert str(host.home) in str(listed.json()) + state = browser.get("/api/daemons/box/state").json() + assert state == {"state": "up", "detail": "", "version": "0.0.0"} + # Written there, and not here. + kept = host.home / "project" + kept.mkdir() + wrote = browser.post("/d/box/api/workspaces", {"workspace": str(kept)}) + assert wrote.status == 200, wrote.body + assert wrote.json()["kept"] == str(kept) + assert str(kept) in (host.humanize / "settings.yaml").read_text() + # A file sent there a piece at a time, refused there for what it is. + sent = browser.sends( + "/d/box/api/runs/import", b"not an epic" * 1000, "x.hmzepic" + ) + assert sent.status not in (502, 503), sent.body + assert "error" in sent.json() + # Its runs, as a page reads them. + assert _asked(browser, "/d/box/api/runs").status == 200 + # And what it was told: nothing asked of anybody, the host's own options first. + told = host.told()[0] + assert told.index("Compression=yes") < told.index("BatchMode=yes") + assert told.index("BatchMode=yes") < told.index("BatchMode=no") + assert "me@box.example" in told + + +def test_local_is_this_machine_by_either_path(host: Host) -> None: + with served() as site: + browser = Browser(site) + browser.signs_in() + assert ( + browser.get("/d/local/api/workspaces").json() + == browser.get("/api/workspaces").json() + ) + assert browser.get("/api/daemons/local/state").json()["state"] == "up" + assert browser.get("/d/nobody/api/runs").status == 404 + assert browser.get("/api/daemons/nobody/state").status == 404 + assert not host.pids() + + +def test_only_the_page_let_in_reaches_another_machine(host: Host) -> None: + with served() as site: + browser = Browser(site) + assert browser.get("/d/box/api/runs").status == 401 + browser.signs_in() + assert ( + browser.get("/d/box/api/runs", **{"Sec-Fetch-Site": "cross-site"}).status + == 403 + ) + assert ( + browser.post( + "/d/box/api/workspaces", {}, Origin="http://elsewhere.example" + ).status + == 403 + ) + assert not host.pids() + + +def test_a_stream_of_events_comes_through_as_it_comes(host: Host) -> None: + with served() as site: + browser = Browser(site) + browser.signs_in() + _asked(browser, "/d/box/api/runs") + connection = http.client.HTTPConnection( + "127.0.0.1", site.port, timeout=PATIENCE + ) + try: + connection.request( + "GET", + "/d/box/api/held/stream", + headers={"Host": f"127.0.0.1:{site.port}", "Cookie": browser.cookie}, + ) + stream = connection.getresponse() + assert stream.status == 200 + assert stream.getheader("Content-Type", "").startswith("text/event-stream") + assert stream.getheader("Set-Cookie") is None + events: list[str] = [] + while not events: + line = stream.readline().decode() + assert line, "the stream ended before any event" + if line.startswith("event: "): + events.append(line.removeprefix("event: ").strip()) + finally: + connection.close() + + +def test_a_daemon_that_cannot_be_reached_says_why( + host: Host, monkeypatch: pytest.MonkeyPatch +) -> None: + del host + written({"name": "gone", "host": "unreachable.example"}) + with served() as site: + browser = Browser(site) + browser.signs_in() + connected = browser.post("/api/daemons/gone/connect") + assert connected.status == 200 + remotes = _remotes(site) + said = _stands(remotes, "gone", "failed") + assert "Could not resolve hostname" in said["detail"] + refused = browser.get("/d/gone/api/runs") + assert refused.status == 503 + assert refused.json()["state"] == "failed" + + +def test_closing_stops_and_reaps_every_process_started(host: Host) -> None: + remotes = Remotes() + remotes.connect("box") + _stands(remotes, "box", "up") + port, key = remotes.reached("box") + assert key + pids = host.pids() + # The probe, the interface there, the forward. + assert len(pids) == 3 + forward = host.told()[-1] + assert "ControlPath=none" in forward + assert any(word.startswith(f"127.0.0.1:{port}:127.0.0.1:") for word in forward) + remotes.close() + assert all(_gone(pid) for pid in pids) + assert remotes.state("box")["state"] == "idle" + with socket.socket() as again: + assert again.connect_ex(("127.0.0.1", port)) != 0 + + +def test_another_version_there_is_replaced_with_this_one(host: Host) -> None: + host.installs("1.0.0") + remotes = Remotes(version="1.2.3") + try: + remotes.connect("box") + said = _stands(remotes, "box", "up", "failed") + assert said == {"state": "up", "detail": "", "version": "1.2.3"} + assert host.installed().split() == [ + "tool", + "install", + "--force", + "--python", + ">=3.12", + "hmz==1.2.3", + ] + finally: + remotes.close() + + +def test_one_missing_there_is_installed(host: Host) -> None: + host.installs(None) + remotes = Remotes(version="1.2.3") + try: + remotes.connect("box") + assert _stands(remotes, "box", "up", "failed")["version"] == "1.2.3" + finally: + remotes.close() + + +def test_one_running_epics_there_is_left_alone(host: Host) -> None: + host.installs("1.0.0") + host.runs_epics() + remotes = Remotes(version="1.2.3") + try: + remotes.connect("box") + said = _stands(remotes, "box", "up", "failed") + assert said["state"] == "up" + assert said["version"] == "1.0.0" + assert "running epics" in said["detail"] + assert not host.installed() + finally: + remotes.close() + + +def test_a_development_build_installs_nothing(host: Host) -> None: + host.installs(None) + remotes = Remotes(version="0.0.0") + try: + remotes.connect("box") + said = _stands(remotes, "box", "failed") + assert said["detail"] == "hmz is not installed on box." + assert not host.installed() + finally: + remotes.close() + + +def test_a_daemon_no_longer_written_down_is_let_go(host: Host) -> None: + with served() as site: + browser = Browser(site) + browser.signs_in() + assert _asked(browser, "/d/box/api/runs").status == 200 + pids = host.pids() + written({"name": "other", "host": "other.example"}) + assert browser.get("/d/box/api/runs").status == 404 + assert browser.get("/api/daemons/box/state").status == 404 + ending = time.monotonic() + PATIENCE + while not all(_gone(pid) for pid in pids): + assert time.monotonic() < ending, "what reached it was never stopped" + time.sleep(0.05) + + +def _remotes(site: Site) -> Any: + assert site.remotes is not None + return site.remotes diff --git a/tests/system/web/doubles_browser.py b/tests/system/web/doubles_browser.py index c3c3b623..cd08156e 100644 --- a/tests/system/web/doubles_browser.py +++ b/tests/system/web/doubles_browser.py @@ -17,7 +17,7 @@ from tests.integration.doubles_web import PATIENCE if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Callable, Generator from playwright.sync_api import Page @@ -52,6 +52,7 @@ def opened( dark: bool = False, user_agent: str | None = None, init: str | None = None, + routed: Callable[[Page], object] | None = None, ) -> Generator[Page]: """The page, in a browser let in by the address, until the block ends. @@ -63,6 +64,7 @@ def opened( dark: Whether the system the browser runs on asks for dark colours. user_agent: What the browser says it is, where not its own: as the macOS app's window. init: A script run in the page before any of its own. + routed: Told the page before it is opened, to answer some of its requests itself. Yields: The page, drawn and with its sidebar up. @@ -87,6 +89,8 @@ def opened( if init: context.add_init_script(init) page = context.new_page() + if routed: + routed(page) failed: list[str] = [] # Bar the browser's word that a resize took more than one frame to settle: a # notice, not a failure, which WebKit raises as an error on a busy machine. diff --git a/tests/system/web/test_web_daemon_bar.py b/tests/system/web/test_web_daemon_bar.py new file mode 100644 index 00000000..70c4952e --- /dev/null +++ b/tests/system/web/test_web_daemon_bar.py @@ -0,0 +1,115 @@ +"""The daemon bar at the foot of `hmz web`'s sidebar, and the picker it opens. + +The bar names the daemon the page is of, its host and how it stands; the picker lists every +daemon the machine serving the page keeps, switches to one, connects one that failed and +opens where they are managed. The daemons are answered here, as the machine would. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_core import install +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, opened + +if TYPE_CHECKING: + from pathlib import Path + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + +_DAEMONS = [ + {"name": "local", "host": "here", "state": "up"}, + {"name": "lab", "host": "lab.example", "state": "up"}, + {"name": "farm", "host": "farm.example", "state": "failed"}, +] + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path: + """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve.""" + return install(tmp_path, monkeypatch) + + +def _answering(status: int, body: object) -> str: + """A script answering the daemons, and connecting one, as a machine would, before the page asks. + + The page is let in by the address as it opens, so the answer is given in the page itself. + """ + return f""" + window.hmzConnected = [] + const fetched = window.fetch.bind(window) + window.fetch = (url, how) => {{ + const path = new URL(String(url), location.href).pathname + const json = (code, said) => new Response(JSON.stringify(said), {{ + status: code, headers: {{ 'Content-Type': 'application/json' }}, + }}) + if (path === '/api/daemons') return Promise.resolve(json({status}, {json.dumps(body)})) + const connect = /^\\/api\\/daemons\\/([^/]+)\\/connect$/.exec(path) + if (connect) {{ + window.hmzConnected.push(connect[1]) + return Promise.resolve(json(200, {{}})) + }} + return fetched(url, how) + }} + """ + + +def test_the_bar_names_the_daemon_and_its_picker_switches( + project: Path, engine: str +) -> None: + with ( + served() as site, + opened(site, engine=engine, init=_answering(200, _DAEMONS)) as page, + ): + bar = page.locator("[data-hmz=daemon-bar]") + expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("Local") + expect(bar.locator("[data-hmz=daemon-host]")).to_have_text("here") + expect(bar.locator("[data-hmz=state]")).to_have_text("idle") + + bar.click() + picker = page.locator("[data-hmz=daemon-picker]") + expect(picker).to_have_attribute("role", "menu") + expect(picker.locator(".choice-label")).to_have_text( + [ + *["Local", "lab", "farm", "Connect", "Manage"], + *["Import", "Settings", "Usage", "Download"], + ] + ) + expect(picker.locator(".choice.chosen .choice-label")).to_have_text("Local") + + # One that failed is connected again. + picker.get_by_role("menuitem", name="Connect").click() + expect(picker).to_have_count(0) + assert page.evaluate("window.hmzConnected") == ["farm"] + + bar.click() + picker.get_by_role("menuitem", name=re.compile(r"^lab")).click() + page.wait_for_url(re.compile(r".*#/@lab/new$")) + expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("lab") + expect(bar.locator("[data-hmz=daemon-host]")).to_have_text("lab.example") + + bar.click() + picker.get_by_role("menuitem", name="Manage").click() + page.wait_for_url(re.compile(r".*#/@lab/settings/daemons$")) + + +def test_without_daemons_the_bar_is_local(project: Path, engine: str) -> None: + missing = {"error": "Nothing is here."} + with ( + served() as site, + opened(site, engine=engine, width=375, init=_answering(404, missing)) as page, + ): + page.locator("[data-hmz=bar] .bar-side").click() + bar = page.locator("[data-hmz=daemon-bar]") + expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("Local") + bar.click() + picker = page.locator("[data-hmz=daemon-picker]") + expect(picker.locator(".choice-label")).to_have_text( + ["Local", "Manage", "Import", "Settings", "Usage", "Download"] + ) diff --git a/tests/system/web/test_web_notification_centre.py b/tests/system/web/test_web_notification_centre.py new file mode 100644 index 00000000..00aa581c --- /dev/null +++ b/tests/system/web/test_web_notification_centre.py @@ -0,0 +1,240 @@ +"""`hmz web`'s notification centre: the bell, its menu, the cards, and the system's own. + +The global notifications are the local server's, of every daemon; here they are answered by +the browser itself, routed before the page asks, so the page is tested alone: a bell with how +many are unread, a menu of them newest first that marks them read and clears them, a card for +each one heard on the stream, and -- while the page is not looked at -- one the system shows, +by the browser's `Notification` or by the macOS app. Each in Chromium and WebKit. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, opened + +if TYPE_CHECKING: + from playwright.sync_api import Page, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + +#: What the macOS app's window says it is. +_APP = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15" + " (KHTML, like Gecko) humanize-desktop/0.0.0" +) + +#: A page that is never looked at, which the browser's and the app's notifications are stubbed in. +_AWAY = """ +Object.defineProperty(document, 'hidden', { get: () => true }) +document.hasFocus = () => false +window.shown = [] +window.Notification = class { + static permission = 'granted' + constructor(title, options) { window.shown.push({ title, ...options }) } + close() {} +} +window.__TAURI_INTERNALS__ = { + invoke: (command, args) => { window.shown.push({ command, ...args }); return Promise.resolve() }, +} +""" + + +def _item(id_: int, kind: str, **more: Any) -> dict[str, Any]: + return { + "id": id_, + "kind": kind, + "daemon": "local", + "workspace": "/w", + "epic": f"e{id_}", + "flow": "chat", + "task": "Fix it", + "role": "", + "text": "", + "at": "2026-10-12T10:00:00Z", + "read": False, + **more, + } + + +@dataclass +class Feed: + """The server's notifications, as the page is answered them, and what it was sent.""" + + items: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]]) + heard: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]]) + system: bool = True + sent: list[tuple[str, Any]] = field(default_factory=list[tuple[str, Any]]) + asked: int = 0 + + def routes(self, page: Page) -> None: + page.route("**/api/notifications", self._list) + page.route("**/api/notifications/stream", self._stream) + page.route("**/api/notifications/settings", self._settings) + page.route("**/api/notifications/read", self._written) + page.route("**/api/notifications/clear", self._written) + + def _list(self, route: Route) -> None: + unread = sum(not one["read"] for one in self.items) + route.fulfill(json={"items": self.items, "unread": unread}) + + def _stream(self, route: Route) -> None: + # What is heard is told once, and kept with the rest from then on, as the server does. + said = "retry: 60000\n\n" + "".join( + f"event: notification\nid: {one['id']}\ndata: {json.dumps(one)}\n\n" + for one in self.heard + ) + self.items[:0] = reversed(self.heard) + self.heard = [] + route.fulfill( + status=200, + headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}, + body=said, + ) + + def _settings(self, route: Route) -> None: + self.asked += 1 + kinds = dict.fromkeys(("done", "failed", "stopped", "asked", "listening"), True) + route.fulfill(json={"kinds": kinds, "system": self.system}) + + def _written(self, route: Route) -> None: + request = route.request + what = request.url.rsplit("/", 1)[1] + said: dict[str, Any] = request.post_data_json or {} + self.sent.append((what, said)) + if what == "clear": + self.items = [] + for one in self.items: + one["read"] = ( + one["read"] or said.get("all") or one["id"] in said.get("ids", []) + ) + route.fulfill(json={}) + + +def test_the_bell_counts_the_unread_and_its_menu_reads_and_clears_them( + engine: str, +) -> None: + feed = Feed( + items=[ + _item(2, "asked", text="Which branch?"), + _item(1, "done", daemon="devbox", read=True), + ] + ) + with served() as site, opened(site, engine=engine, routed=feed.routes) as page: + bell = page.locator("[data-hmz=notifications]") + expect(bell).to_be_visible() + expect(bell.locator("[data-hmz=notification-count]")).to_have_text("1") + + bell.click() + menu = page.locator("[data-hmz=notification-menu]") + expect(menu.locator("[data-hmz=notification] .choice-label")).to_have_text( + ["Question · Local", "Done · devbox"] + ) + expect(menu.locator("[data-hmz=notification]").first).to_contain_text( + "Which branch?" + ) + expect(menu.locator(".notify-unread")).to_have_count(1) + + menu.locator("[data-hmz=notifications-read]").click() + expect(menu.locator(".notify-unread")).to_have_count(0) + expect(bell.locator("[data-hmz=notification-count]")).to_be_hidden() + assert feed.sent == [("read", {"all": True})] + + menu.locator("[data-hmz=notifications-clear]").click() + expect(menu.locator("[data-hmz=notification]")).to_have_count(0) + expect(menu).to_contain_text("Nothing yet") + assert feed.sent[-1] == ("clear", {}) + + +def test_an_item_picked_is_read_and_opens_what_it_is_about(engine: str) -> None: + feed = Feed(items=[_item(3, "listening", daemon="devbox", workspace="/w/app")]) + with served() as site, opened(site, engine=engine, routed=feed.routes) as page: + page.locator("[data-hmz=notifications]").click() + page.locator("[data-hmz=notification]").click() + expect(page.locator("[data-hmz=notification-menu]")).to_have_count(0) + assert page.evaluate("location.hash") == "#/@devbox/live?workspace=%2Fw%2Fapp" + assert feed.sent == [("read", {"ids": [3]})] + + +def test_a_notification_heard_is_a_card_that_opens_it(engine: str) -> None: + feed = Feed(heard=[_item(4, "failed", daemon="devbox", epic="big-fix")]) + with served() as site, opened(site, engine=engine, routed=feed.routes) as page: + card = page.locator( + "[data-hmz=notification-toasts] [data-hmz=notification-toast]" + ) + expect(card).to_have_count(1) + expect(card).to_contain_text("Failed · devbox") + expect(page.locator("[data-hmz=notification-count]")).to_have_text("1") + card.locator(".notify-open").click() + assert page.evaluate("location.hash") == "#/@devbox/epics/big-fix" + expect(card).to_have_count(0) + expect(page.locator("[data-hmz=notification-count]")).to_be_hidden() + + +def test_there_is_no_bell_where_the_server_has_no_notifications(engine: str) -> None: + def older(page: Page) -> None: + # A server of an older humanize, which has none to give. + page.route( + re.compile(r".*/api/notifications(/.*|\?.*)?$"), + lambda route: route.fulfill(status=404, json={"error": "Not found."}), + ) + + with served() as site, opened(site, engine=engine, routed=older) as page: + expect(page.locator("[data-hmz=sidebar] .brand")).to_be_visible() + expect(page.locator("[data-hmz=notifications]")).to_be_hidden() + + +def test_the_bell_is_there_where_the_server_has_notifications(engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + expect(page.locator("[data-hmz=notifications]")).to_be_visible() + + +@pytest.mark.parametrize("system", [True, False]) +def test_the_browser_shows_one_while_the_page_is_not_looked_at( + engine: str, system: bool +) -> None: + feed = Feed(heard=[_item(5, "done", text="All green")], system=system) + with ( + served() as site, + opened(site, engine=engine, init=_AWAY, routed=feed.routes) as page, + ): + expect(page.locator("[data-hmz=notification-toast]")).to_have_count(1) + if system: + page.wait_for_function("window.shown.length > 0") + assert page.evaluate("window.shown") == [ + {"title": "Done · Local", "body": "chat · All green", "tag": "5"} + ] + else: + # The settings are read before the stream is opened: once they are answered, the + # page has had all it needs to show one, and must have shown none. + while not feed.asked: + page.wait_for_timeout(50) + page.wait_for_timeout(500) + assert page.evaluate("window.shown") == [] + + +def test_the_app_shows_one_through_its_own_notifications(engine: str) -> None: + feed = Feed(heard=[_item(6, "asked", text="Ship it?")]) + with ( + served() as site, + opened( + site, engine=engine, user_agent=_APP, init=_AWAY, routed=feed.routes + ) as page, + ): + expect(page.locator("[data-hmz=notification-toast]")).to_have_count(1) + page.wait_for_function("window.shown.length > 0") + assert page.evaluate("window.shown") == [ + { + "command": "notify", + "title": "Question · Local", + "body": "chat · Ship it?", + "address": "#/live?workspace=%2Fw", + } + ] diff --git a/tests/system/web/test_web_page.py b/tests/system/web/test_web_page.py index fe5ea1e8..aa3706b2 100644 --- a/tests/system/web/test_web_page.py +++ b/tests/system/web/test_web_page.py @@ -423,10 +423,10 @@ def test_the_colours_are_a_pair_under_auto_or_a_theme( project: Path, engine: str ) -> None: with served() as site, opened(site, engine=engine) as page: - # A theme is chosen only in Settings: the machine's menu offers none. - page.locator("[data-hmz=machine-menu]").click() + # A theme is chosen only in Settings: the daemon picker offers none. + page.locator("[data-hmz=daemon-bar]").click() expect(page.locator("[role=menu] .choice-label")).to_have_text( - ["Import", "Settings", "Usage", "Download"] + ["Local", "Manage", "Import", "Settings", "Usage", "Download"] ) page.keyboard.press("Escape") go(page, "/settings/themes") @@ -598,11 +598,13 @@ def test_a_newer_release_is_offered_under_the_machine( releases.fetch() with served() as site, opened(site, engine=engine) as page: expect(page.locator("[data-hmz=newer]")).to_be_visible() - page.locator("[data-hmz=machine-menu]").click() + page.locator("[data-hmz=daemon-bar]").click() expect(page.locator("[role=menu] .choice-label")).to_have_text( - ["Update", "Import", "Settings", "Usage", "Download"] + ["Local", "Manage", "Update", "Import", "Settings", "Usage", "Download"] + ) + expect(page.locator("[role=menu] .choice-about").last).to_have_text( + "hmz 99.0.0" ) - expect(page.locator("[role=menu] .choice-about")).to_have_text(["hmz 99.0.0"]) def test_a_flow_is_opened_and_started_from_the_flows( @@ -662,7 +664,7 @@ def left(page: Page, selector: str) -> float: ) expect(page.locator(f":is({clicked})[data-tauri-drag-region]")).to_have_count(0) # A menu opened in a row is not what the window is dragged by. - page.locator("[data-hmz=machine-menu]").click() + page.locator("[data-hmz=daemon-bar]").click() expect(page.locator("[role=menu]")).to_have_attribute( "data-tauri-drag-region", "false" ) @@ -850,7 +852,7 @@ def test_an_epic_backed_up_elsewhere_is_downloaded_and_opened( ) -> None: name = backed_up_elsewhere(tmp_path, monkeypatch) with served() as site, opened(site, engine=engine) as page: - page.locator("[data-hmz=machine-menu]").click() + page.locator("[data-hmz=daemon-bar]").click() page.get_by_role("menuitem", name="Download").click() stored = page.locator(f"[data-hmz=stored-epic][data-epic='{name}']") @@ -1012,9 +1014,9 @@ def gone() -> None: page.wait_for_url(opened_on) expect(listed).to_have_count(1) - # Picked again, from the machine's menu: it is here already, and is opened from there. + # Picked again, from the daemon picker: it is here already, and is opened from there. go(page, "/new") - page.locator("[data-hmz=machine-menu]").click() + page.locator("[data-hmz=daemon-bar]").click() with page.expect_file_chooser() as choosing: page.get_by_role("menuitem", name="Import").click() choosing.value.set_files(archive) diff --git a/tests/system/web/test_web_pointer.py b/tests/system/web/test_web_pointer.py index 4550c4b7..54121e61 100644 --- a/tests/system/web/test_web_pointer.py +++ b/tests/system/web/test_web_pointer.py @@ -231,9 +231,9 @@ def test_every_popup_opens_whole_within_the_window(project: Path, engine: str) - page.keyboard.press("Escape") page.keyboard.press("Escape") - # The machine's menu, at the foot of the window: over its button, as wide as it. - page.locator("[data-hmz=machine-menu]").click() - _whole(page, page.get_by_role("menu", name="Machine")) + # The daemon picker, at the foot of the window: over its button, as wide as it. + page.locator("[data-hmz=daemon-bar]").click() + _whole(page, page.get_by_role("menu", name="Daemon")) page.keyboard.press("Escape") # A path browsed for from the folder a new epic opens, in a window too short for it. diff --git a/tests/system/web/test_web_scoping.py b/tests/system/web/test_web_scoping.py new file mode 100644 index 00000000..b77ad352 --- /dev/null +++ b/tests/system/web/test_web_scoping.py @@ -0,0 +1,152 @@ +"""`hmz web`'s page on one daemon at a time: the daemon is the first part of every address. + +`#/@/` is a view of that daemon, and an address naming none is of Local. Whatever +the page asks of the daemon it is on goes under `/d//api/`, every link it draws keeps the +daemon its address named, and going to another daemon's address tells the page it switched. +Each in Chromium and in WebKit. +""" + +from __future__ import annotations + +import re +import urllib.parse +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_core import install +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, go, opened + +if TYPE_CHECKING: + from pathlib import Path + + from playwright.sync_api import Page, Request, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path: + """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve.""" + return install(tmp_path, monkeypatch) + + +def _asked(page: Page) -> list[str]: + """Every path of the API the page asks from here on, as it asks it.""" + paths: list[str] = [] + + def heard(request: Request) -> None: + path = urllib.parse.urlsplit(request.url).path + # What is the same whichever daemon is shown is asked of the page's own server. + if "/api/" in path and not re.match(r"/api/(daemons|notifications)\b", path): + paths.append(path) + + page.on("request", heard) + return paths + + +def _other(page: Page) -> None: + """A daemon named `other`, which is Local answering under another name.""" + + def answered(route: Route) -> None: + route.continue_(url=route.request.url.replace("/d/other/api/", "/d/local/api/")) + + page.route(re.compile(r".*/d/other/api/.*"), answered) + + +def test_an_address_naming_local_draws_every_view_and_keeps_naming_it( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _asked(page) + + go(page, "/@local/new") + expect(page.locator("[data-hmz=flow-picker] b")).to_have_text("chat") + nav = page.locator(".side-nav a") + expect(nav).to_have_count(3) + for one in nav.all(): + expect(one).to_have_attribute("href", re.compile(r"^#/@local/")) + expect(page.locator(".side-top .brand")).to_have_attribute( + "href", "#/@local/new" + ) + + page.locator(".side-nav a[data-nav=flows]").click() + expect(page.locator("[data-hmz=flows] .card").first).to_be_visible() + assert page.evaluate("location.hash").startswith("#/@local/flows") + + go(page, "/@local/settings/general") + dialog = page.locator("[data-hmz=dialog-settings]") + expect(dialog).to_be_visible() + expect(dialog.locator("nav a").first).to_have_attribute( + "href", re.compile(r"^#/@local/settings/") + ) + page.keyboard.press("Escape") + expect(dialog).to_have_count(0) + assert page.evaluate("location.hash").startswith("#/@local/flows") + + assert asked + assert all(path.startswith("/d/local/api/") for path in asked), asked + + +def test_an_epic_started_on_a_daemon_stays_on_it(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _asked(page) + go(page, "/@local/new?flow=chat") + page.get_by_role("button", name="Options").click() + expect( + page.locator("[data-role=assistant] [data-hmz=pick-model]") + ).to_have_value("claude-haiku-4-5") + page.locator("[data-hmz=composer-text]").fill("hello there") + page.locator("[data-hmz=composer-text]").press("Enter") + page.wait_for_url(re.compile(r".*#/@local/epics/[^/]+/sessions/assistant%2F1$")) + expect(page.locator("[data-hmz=thread]")).to_contain_text("did: hello there") + + # The epic's stream, and its links, are the daemon's. + assert any(path == "/d/local/api/held/stream" for path in asked), asked + expect(page.locator("[data-hmz=epic]").first).to_have_attribute( + "href", re.compile(r"^#/@local/epics/") + ) + page.get_by_role("button", name="Stop").click() + page.get_by_role("button", name="Stop").click() + expect(page.locator("[data-hmz=bar] .badge")).to_have_text( + re.compile("stopped") + ) + + +def test_another_daemon_is_switched_to_and_back(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + _other(page) + page.evaluate( + """() => { + window.switched = [] + addEventListener('hmz:daemon', (event) => switched.push(event.detail.daemon)) + }""" + ) + asked = _asked(page) + + # The same daemon, named, is no switch. + go(page, "/@local/new") + assert page.evaluate("switched") == [] + + go(page, "/@other/flows") + assert page.evaluate("switched") == ["other"] + expect(page.locator("[data-hmz=flows] .card").first).to_be_visible() + expect(page.locator(".side-nav a[data-nav=new]")).to_have_attribute( + "href", "#/@other/new" + ) + expect(page.locator("[data-hmz=workspace]").first).to_be_visible() + since = len(asked) + page.locator(".side-nav a[data-nav=new]").click() + expect(page.locator("[data-hmz=flow-picker] b")).to_have_text("chat") + assert page.evaluate("location.hash").startswith("#/@other/new?") + later = asked[since:] + assert later + assert all(path.startswith("/d/other/api/") for path in later), later + + go(page, "/new") + assert page.evaluate("switched") == ["other", "local"] + expect(page.locator(".side-nav a[data-nav=flows]")).to_have_attribute( + "href", "#/flows" + ) diff --git a/tests/system/web/test_web_settings_daemons.py b/tests/system/web/test_web_settings_daemons.py new file mode 100644 index 00000000..d70aeb28 --- /dev/null +++ b/tests/system/web/test_web_settings_daemons.py @@ -0,0 +1,178 @@ +"""Settings, per daemon. + +The app's own pages apart from a daemon's, which daemon those read and write, and what a page +holds copied to several daemons at once. + +The daemons are the browser's to fake: the list of them is answered in the page's browser, and +so is everything sent to the remote ones -- `gpu` mirrors what Local reads and takes whatever +is written to it, and `lab` refuses everything, as a daemon that is not up does. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING, Any + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_core import install +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, go, opened + +if TYPE_CHECKING: + from pathlib import Path + + from playwright.sync_api import Page, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + +#: Every daemon the page is told of: Local, one that is up and one that is not. +_DAEMONS = { + "daemons": [ + {"name": "local", "state": "up"}, + {"name": "gpu", "host": "gpu.example", "state": "up"}, + {"name": "lab", "host": "lab.example", "state": "failed"}, + ] +} + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path: + """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve.""" + return install(tmp_path, monkeypatch) + + +def _daemons(page: Page) -> list[tuple[str, str, Any]]: + """Fakes every daemon but Local, keeping what each was asked: its name, method and body.""" + asked: list[tuple[str, str, Any]] = [] + + def listed(route: Route) -> None: + route.fulfill(json=_DAEMONS) + + def remote(route: Route) -> None: + request = route.request + found = re.match(r".*/d/([^/]+)(/api/.*)", request.url) + assert found + name, path = found.groups() + if name == "local": + # The page's own daemon, which it asks as it asks everything: no fake of it. + route.fallback() + return + body = json.loads(request.post_data) if request.post_data else None + asked.append((name, f"{request.method} {path.split('?')[0]}", body)) + if name == "lab": + route.fulfill(status=409, json={"error": "lab is not up."}) + elif request.method == "POST": + route.fulfill(json={}) + else: + route.fulfill(response=route.fetch(url=request.url.replace("/d/gpu", ""))) + + page.route(re.compile(r".*/api/daemons$"), listed) + page.route(re.compile(r".*/d/[^/]+/api/.*"), remote) + return asked + + +def test_the_app_and_the_daemon_are_set_apart(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + go(page, "/settings/general") + nav = page.locator(".settings-nav") + expect(nav.locator(".settings-group")).to_have_text(["App", "Daemon"]) + expect(nav.locator("a:visible").first).to_have_text("Themes") + expect(nav.locator("a:visible")).to_contain_text( + ["Themes", "General", "Accounts", "Fallback", "Runtimes", "Backup"] + ) + picker = page.locator("[data-hmz=settings-daemon]") + expect(picker.locator("option")).to_have_text(["Local"]) + go(page, "/settings/themes") + expect(page.locator("[data-hmz=settings-daemon]")).to_have_count(0) + + +def test_the_daemon_picked_is_the_one_read_and_written( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _daemons(page) + go(page, "/settings/general") + picker = page.locator("[data-hmz=settings-daemon]") + expect(picker.locator("option")).to_have_text(["Local", "gpu", "lab"]) + expect(picker).to_have_value("local") + assert not asked + + picker.select_option("gpu") + expect(page.locator("[data-hmz=btw-whom]")).to_be_visible() + assert ("gpu", "GET /api/settings", None) in asked + assert ("gpu", "GET /api/backends", None) in asked + assert "daemon=gpu" in page.url + expect(page.locator(".settings-nav a", has_text="Backup")).to_have_attribute( + "href", "#/settings/backup?daemon=gpu" + ) + + page.get_by_role("button", name="Save").click() + expect(page.get_by_role("button", name="Saved")).to_be_visible() + assert any(one[:2] == ("gpu", "POST /api/settings") for one in asked) + + # Kept in the address, the daemon is the one the page opens on again. + asked.clear() + go(page, "/settings/backup?daemon=gpu") + expect(page.locator("[data-hmz=backups] h2")).to_have_text("Targets") + expect(page.locator("[data-hmz=settings-daemon]")).to_have_value("gpu") + assert ("gpu", "GET /api/settings", None) in asked + + +def test_batch_apply_sends_the_page_to_each_daemon_ticked( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _daemons(page) + go(page, "/settings/general") + page.locator("[data-hmz=settings-general] input[type=checkbox]").check() + page.locator("[data-hmz=batch-apply]").click() + dialog = page.locator("[data-hmz=batch-dialog]") + expect(dialog.locator("label.check")).to_have_text(["All", "gpu", "lab"]) + dialog.locator("[data-hmz=batch-all]").check() + dialog.locator("[data-hmz=batch-go]").click() + + results = dialog.locator("[data-hmz=batch-result]") + expect(results.and_(page.locator("[data-daemon=gpu]"))).to_contain_text( + "Applied" + ) + expect(results.and_(page.locator("[data-daemon=lab]"))).to_contain_text( + "lab is not up." + ) + sent = [one for one in asked if one[1] == "POST /api/settings"] + assert sorted(one[0] for one in sent) == ["gpu", "lab"] + assert all(one[2]["details"] is True for one in sent) + + # Closed, it leaves Settings open; nothing was written on Local. + page.keyboard.press("Escape") + expect(dialog).to_have_count(0) + expect(page.locator("[data-hmz=settings-general]")).to_be_visible() + + +def test_accounts_keep_their_secrets_on_their_daemon( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + _daemons(page) + go(page, "/settings/accounts") + expect(page.locator("[data-hmz=batch-apply]")).to_be_disabled() + expect(page.locator("[data-hmz=batch-off]")).to_have_text("Secrets stay put") + go(page, "/settings/usage") + expect(page.locator("[data-hmz=usage] .panel").first).to_be_visible() + expect(page.locator("[data-hmz=batch-apply]")).to_have_count(0) + + +def test_settings_fit_a_phone(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine, width=390, height=800) as page: + _daemons(page) + go(page, "/settings/runtimes") + expect(page.locator("[data-hmz=settings-daemon]")).to_be_visible() + page.locator("[data-hmz=batch-apply]").click() + dialog = page.locator("[data-hmz=batch-dialog]") + expect(dialog).to_be_visible() + box = dialog.bounding_box() + assert box + assert box["x"] >= 0 + assert box["x"] + box["width"] <= 390 diff --git a/tests/system/web/test_web_settings_pages.py b/tests/system/web/test_web_settings_pages.py new file mode 100644 index 00000000..33e889e8 --- /dev/null +++ b/tests/system/web/test_web_settings_pages.py @@ -0,0 +1,261 @@ +"""`hmz web`'s Daemons and Notifications settings pages, driven in a browser. + +The page is the real one, served in the test's own process; what it asks of the daemons and the +notification settings is answered by fakes routed in the browser, which keep what is written to +them, so that what the page sent is what is checked. Each test runs in Chromium and in WebKit. +""" + +from __future__ import annotations + +import json +import re +import urllib.parse +from typing import TYPE_CHECKING, Any + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_core import install +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, go, opened + +if TYPE_CHECKING: + from pathlib import Path + + from playwright.sync_api import Page, Request, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path: + """The stand-in `claude` alone on PATH, and a project to serve.""" + return install(tmp_path, monkeypatch) + + +class _Daemons: + """The daemon registry, as the page asks it: kept in a list, and every write recorded.""" + + def __init__(self) -> None: + self.daemons: list[dict[str, Any]] = [ + { + "name": "box", + "host": "box.example", + "user": "me", + "port": 22, + "state": "up", + } + ] + self.hosts = [{"alias": "gpu", "host": "gpu.example"}, {"alias": "cpu"}] + self.writes: list[tuple[str, Any]] = [] + + def listed(self) -> dict[str, Any]: + return {"daemons": [{"name": "local", "state": "up"}, *self.daemons]} + + def answer(self, route: Route) -> None: + path = urllib.parse.urlsplit(route.request.url).path + body = json.loads(route.request.post_data or "{}") + if route.request.method == "POST": + self.writes.append((path, body)) + if path == "/api/daemons/hosts": + said: Any = {"hosts": self.hosts} + elif path == "/api/daemons/import": + self.daemons += [{"name": one, "host": one} for one in body["aliases"]] + self.hosts = [ + one for one in self.hosts if one["alias"] not in body["aliases"] + ] + said = self.listed() + elif path == "/api/daemons/remove": + self.daemons = [one for one in self.daemons if one["name"] != body["name"]] + said = self.listed() + elif path.endswith("/connect"): + route.fulfill(status=409, json={"error": "it did not answer"}) + return + elif route.request.method == "POST": + self.daemons = [one for one in self.daemons if one["name"] != body["name"]] + self.daemons.append({"name": body["name"], **body["fields"]}) + said = self.listed() + else: + said = self.listed() + route.fulfill(json=said) + + +def _daemons(page: Page) -> _Daemons: + fake = _Daemons() + page.route("**/api/daemons**", fake.answer) + return fake + + +def test_a_daemon_is_added_a_part_at_a_time(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + fake = _daemons(page) + go(page, "/settings/daemons") + rows = page.locator("[data-hmz=daemon]") + expect(rows).to_have_count(2) + expect(rows.first).to_contain_text("Local") + expect(rows.first.get_by_role("button")).to_have_count(0) + form = page.locator("[data-hmz=write-daemon]") + form.get_by_label("Name").fill("lab") + form.get_by_label("Host").fill("lab.example") + form.get_by_label("User").fill("ann") + form.get_by_label("Port").fill("2222") + form.locator("#daemon-identity").fill("~/.ssh/lab") + form.get_by_label("Proxy jump").fill("me@jump") + form.get_by_role("button", name="Add", exact=True).first.click() + form.locator(".pair-key").fill("ServerAliveInterval") + form.locator(".pair-value").fill("30") + form.get_by_role("button", name="Add", exact=True).last.click() + expect(rows).to_have_count(3) + expect(page.locator("[data-hmz=daemon][data-name=lab]")).to_contain_text( + "ann@lab.example:2222" + ) + assert fake.writes[-1] == ( + "/api/daemons", + { + "name": "lab", + "fields": { + "host": "lab.example", + "user": "ann", + "port": 2222, + "identity_file": "~/.ssh/lab", + "proxy_jump": "me@jump", + "options": [["ServerAliveInterval", "30"]], + }, + "replace": False, + }, + ) + + +def test_hosts_are_imported_from_the_ssh_config(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + fake = _daemons(page) + go(page, "/settings/daemons") + importing = page.locator("[data-hmz=import-daemons]") + importing.get_by_role("button", name="Read SSH config").click() + importing.locator("[data-alias=gpu]").check() + importing.get_by_role("button", name="Import").click() + expect(page.locator("[data-hmz=daemon][data-name=gpu]")).to_be_visible() + assert fake.writes[-1] == ("/api/daemons/import", {"aliases": ["gpu"]}) + + +def test_a_daemon_is_removed_once_confirmed(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + fake = _daemons(page) + go(page, "/settings/daemons") + row = page.locator("[data-hmz=daemon][data-name=box]") + row.get_by_role("button", name="Remove").click() + assert not fake.writes + row.get_by_role("button", name="Cancel").click() + row.get_by_role("button", name="Remove").click() + row.get_by_role("button", name="Remove").click() + expect(row).to_have_count(0) + assert fake.writes == [("/api/daemons/remove", {"name": "box"})] + + +def test_a_refused_connection_is_said_where_it_happened( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + _daemons(page) + go(page, "/settings/daemons") + row = page.locator("[data-hmz=daemon][data-name=box]") + row.get_by_role("button", name="Connect").click() + expect(row.locator(".error")).to_have_text("it did not answer") + + +def test_without_the_endpoints_the_pages_say_unavailable( + project: Path, engine: str +) -> None: + def older(page: Page) -> None: + # A server of an older humanize, which has neither. + page.route( + re.compile(r".*/api/(daemons|notifications)(/.*|\?.*)?$"), + lambda route: route.fulfill(status=404, json={"error": "Not found."}), + ) + + with served() as site, opened(site, engine=engine, routed=older) as page: + for name in ("daemons", "notifications"): + go(page, f"/settings/{name}") + body = page.locator(f"[data-hmz=settings-{name}]") + expect(body.locator(".empty")).to_contain_text("Unavailable") + expect(body.locator(".error")).to_have_count(0) + + +def _posted(request: Request) -> bool: + return request.method == "POST" + + +#: The browser's notifications, as a page that was never asked sees them, granting when asked. +_NOTIFICATION = """ +window.Notification = class { constructor() {} } +window.Notification.permission = 'default' +window.Notification.requestPermission = async () => { + window.__asked = (window.__asked || 0) + 1 + window.Notification.permission = 'granted' + return 'granted' +} +""" + + +def test_notification_kinds_are_saved_as_they_are_toggled( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine, init=_NOTIFICATION) as page: + saved: list[Any] = [] + settings = {"kinds": {"done": True, "failed": False}, "system": True} + + def answer(route: Route) -> None: + if route.request.method == "POST": + saved.append(json.loads(route.request.post_data or "{}")) + settings.update(saved[-1]) + route.fulfill(json=settings) + + page.route("**/api/notifications/settings", answer) + go(page, "/settings/notifications") + expect(page.locator("[data-hmz=notify-failed]")).not_to_be_checked() + expect(page.locator("[data-hmz=notify-asked]")).to_be_checked() + for label in ("Done", "Failed", "Stopped", "Questions", "Chat turns"): + expect(page.get_by_label(label, exact=True)).to_be_visible() + with page.expect_request(_posted): + page.get_by_label("Questions", exact=True).uncheck() + expect(page.locator("[data-hmz=notifications-said]")).to_have_text("Saved") + with page.expect_request(_posted): + page.locator("[data-hmz=notify-system]").uncheck() + expect(page.locator("[data-hmz=notifications-said]")).to_have_text("Saved") + assert saved == [ + { + "kinds": { + "done": True, + "failed": False, + "stopped": True, + "asked": False, + "listening": True, + }, + "system": True, + }, + { + "kinds": { + "done": True, + "failed": False, + "stopped": True, + "asked": False, + "listening": True, + }, + "system": False, + }, + ] + + +def test_notifications_are_allowed_from_a_click(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine, init=_NOTIFICATION) as page: + page.route( + "**/api/notifications/settings", + lambda route: route.fulfill(json={"kinds": {}, "system": True}), + ) + go(page, "/settings/notifications") + state = page.locator("[data-hmz=permission-state]") + expect(state).to_have_text("Not asked") + page.locator("[data-hmz=allow-notifications]").click() + expect(state).to_have_text("Allowed") + expect(page.locator("[data-hmz=allow-notifications]")).to_have_count(0) + assert page.evaluate("window.__asked") == 1 diff --git a/tests/unit/runtime/doing/test_hosting.py b/tests/unit/runtime/doing/test_hosting.py index 16b0843c..0b12a513 100644 --- a/tests/unit/runtime/doing/test_hosting.py +++ b/tests/unit/runtime/doing/test_hosting.py @@ -1039,6 +1039,57 @@ def test_a_question_is_put_to_everybody_and_answered_by_number( run.released.set() +def test_the_status_says_the_questions_waited_on_and_how_the_last_run_ended( + host: Host, where: Workspace, outworlders: Outworlders, tmp_path: Path +) -> None: + alice = Told(host, "alice") + where.next_run = FakeRun(epic=tmp_path / "20260101-ralph") + run = _started(alice, where) + assert outworlders.ask is not None + said = host.status() + assert (said["epic"], said["task"], said["pending"], said["ended"]) == ( + "20260101-ralph", + "fix it", + [], + None, + ) + + asking = Asking()(outworlders.ask, Question("which?", ("red", "blue"))) + asked = alice.told("asked", run=1) + + assert host.status()["pending"] == [ + { + "question": asked["question"], + "role": "outworlder", + "text": "which?", + "mode": "ask", + } + ] + alice.asks("answer", question=asked["question"], text="1") + assert asking.done.wait(PATIENCE) + run.raises = BudgetExceeded("spent") + run.released.set() + alice.told("ended", run=1) + said = host.status() + assert said["pending"] == [] + ended = said["ended"] + assert ( + ended["how"], + ended["epic"], + ended["run"], + ended["flow"], + ended["task"], + ) == ( + "budget", + "20260101-ralph", + 1, + "ralph", + "fix it", + ) + assert "spent" in ended["why"] + assert ended["at"] > 0 + + def test_a_question_of_a_claimed_role_is_its_claimants( host: Host, where: Workspace, outworlders: Outworlders ) -> None: diff --git a/tests/unit/web/test_daemons.py b/tests/unit/web/test_daemons.py new file mode 100644 index 00000000..5ae9634c --- /dev/null +++ b/tests/unit/web/test_daemons.py @@ -0,0 +1,219 @@ +"""`hmz.web.daemons`: the daemons a page may switch between, written down and brought in.""" + +from __future__ import annotations + +import copy +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +import pytest + +from hmz.web.daemons import LOCAL, ROUTES, found, listed, saved +from hmz.web.routing import Asked, Refusal + +if TYPE_CHECKING: + from collections.abc import Callable + + +@pytest.fixture +def held(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """The settings file, as a dict that `read` and `changes` read and write.""" + from hmz.coganchor import settings + + file: dict[str, Any] = {"other": {"kept": True}} + + def changes(change: Callable[[dict[str, Any]], None]) -> dict[str, Any]: + working = copy.deepcopy(file) + change(working) + file.clear() + file.update(working) + return file + + monkeypatch.setattr(settings, "read", lambda: copy.deepcopy(file)) + monkeypatch.setattr(settings, "changes", changes) + return file + + +@pytest.fixture +def config(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """The hosts the ssh config names, each resolving to `.example`.""" + from hmz.coganchor.machines import sshconfig + + named = ["gpu", "box.one", "local", "a+b", "a-b", "__", "x$y"] + + def resolve(alias: str, *_: object, **__: object) -> sshconfig.SSHHost: + return sshconfig.SSHHost( + alias=alias, host=f"{alias}.example", user="me", port=22 + ) + + def aliases(*_: object) -> list[str]: + return list(named) + + monkeypatch.setattr(sshconfig, "aliases", aliases) + monkeypatch.setattr(sshconfig, "resolve", resolve) + return named + + +def _asks(method: str, path: str, body: dict[str, Any] | None = None) -> Any: + route = next(one for one in ROUTES if one.match(method, path) is not None) + return route.answers(Asked(site=SimpleNamespace(), body=body or {})) + + +def test_local_is_first_and_up_with_nothing_written_down(held: dict[str, Any]) -> None: + daemons = listed(SimpleNamespace())["daemons"] + + assert [(one["name"], one["label"], one["state"]) for one in daemons] == [ + (LOCAL, "Local", "up") + ] + + +def test_a_daemon_is_written_with_its_options_as_pairs(held: dict[str, Any]) -> None: + said = _asks( + "POST", + "/api/daemons", + { + "name": "gpu", + "fields": { + "host": "gpu.example", + "port": 2222, + "options": [["Compression", "yes"]], + }, + }, + ) + + assert [one["name"] for one in said["daemons"]] == [LOCAL, "gpu"] + assert said["daemons"][1]["state"] == "idle" + assert held["other"] == {"kept": True} + assert held["daemons"] == [ + { + "name": "gpu", + "host": "gpu.example", + "user": "", + "port": 2222, + "identity_file": "", + "proxy_jump": "", + "alias": "", + "config": "", + "options": [["Compression", "yes"]], + } + ] + assert found("gpu") == held["daemons"][0] + assert found("nope") is None + + +def test_one_there_already_is_replaced_only_when_asked(held: dict[str, Any]) -> None: + _asks("POST", "/api/daemons", {"name": "gpu", "fields": {"host": "a"}}) + + with pytest.raises(Refusal) as refused: + _asks("POST", "/api/daemons", {"name": "gpu", "fields": {"host": "b"}}) + assert refused.value.status == 409 + _asks( + "POST", + "/api/daemons", + {"name": "gpu", "fields": {"host": "b"}, "replace": True}, + ) + + assert [one["host"] for one in saved()] == ["b"] + + +@pytest.mark.parametrize( + "body", + [ + {"name": "local", "fields": {"host": "a"}}, + {"name": "Local", "fields": {"host": "a"}}, + {"name": "a b", "fields": {"host": "a"}}, + {"name": "..", "fields": {"host": "a"}}, + {"name": "gpu", "fields": {}}, + {"name": "gpu", "fields": {"host": "a", "workdir": "/w"}}, + {"name": "gpu", "fields": {"host": "a"}, "secret": "x"}, + {"name": "gpu", "fields": {"host": "a", "port": "22"}}, + {"name": "gpu", "fields": {"host": "a", "options": [["Port", "22"]]}}, + ], +) +def test_what_cannot_be_a_daemon_is_refused( + held: dict[str, Any], body: dict[str, Any] +) -> None: + with pytest.raises(Refusal): + _asks("POST", "/api/daemons", body) + + assert "daemons" not in held + + +def test_a_daemon_is_removed_and_local_never_is(held: dict[str, Any]) -> None: + _asks("POST", "/api/daemons", {"name": "gpu", "fields": {"host": "a"}}) + + with pytest.raises(Refusal): + _asks("POST", "/api/daemons/remove", {"name": "local"}) + with pytest.raises(Refusal) as missing: + _asks("POST", "/api/daemons/remove", {"name": "nope"}) + assert missing.value.status == 404 + said = _asks("POST", "/api/daemons/remove", {"name": "gpu"}) + + assert [one["name"] for one in said["daemons"]] == [LOCAL] + assert held["daemons"] == [] + + +def test_one_written_wrong_by_hand_is_left_out(held: dict[str, Any]) -> None: + held["daemons"] = [ + "nonsense", + {"name": "local", "host": "a"}, + {"name": "ok", "host": "a"}, + {"name": "ok", "host": "b"}, + {"host": "nameless"}, + ] + + assert [(one["name"], one["host"]) for one in saved()] == [("ok", "a")] + + +def test_each_stands_as_the_remote_reach_says(held: dict[str, Any]) -> None: + held["daemons"] = [{"name": "gpu", "host": "a"}] + + def state(name: str) -> dict[str, str]: + return {"state": "failed", "detail": f"{name} down", "version": ""} + + remotes = SimpleNamespace(state=state) + + daemons = listed(SimpleNamespace(remotes=remotes))["daemons"] + + assert daemons[0]["state"] == "up" + assert (daemons[1]["state"], daemons[1]["detail"]) == ("failed", "gpu down") + + +def test_hosts_not_yet_added_are_offered( + held: dict[str, Any], config: list[str] +) -> None: + held["daemons"] = [{"name": "g", "alias": "gpu"}] + + hosts = _asks("GET", "/api/daemons/hosts")["hosts"] + + # Of two called the same, the first; never one no daemon could be. + assert [(one["alias"], one["name"], one["host"]) for one in hosts] == [ + ("box.one", "box.one", "box.one.example"), + ("a+b", "a-b", "a+b.example"), + ] + + +def test_hosts_are_imported_by_alias_skipping_any_already_there( + held: dict[str, Any], config: list[str] +) -> None: + held["daemons"] = [{"name": "gpu", "host": "a"}] + + said = _asks("POST", "/api/daemons/import", {"aliases": ["gpu", "box.one"]}) + + assert said["added"] == ["box.one"] + assert [(one["name"], one["alias"], one["host"]) for one in saved()] == [ + ("gpu", "", "a"), + ("box.one", "box.one", ""), + ] + + +@pytest.mark.parametrize( + "body", [{"aliases": []}, {"aliases": ["elsewhere"]}, {"aliases": ["local"]}] +) +def test_an_import_of_nothing_or_of_a_host_it_cannot_be_is_refused( + held: dict[str, Any], config: list[str], body: dict[str, Any] +) -> None: + with pytest.raises(Refusal): + _asks("POST", "/api/daemons/import", body) + + assert "daemons" not in held diff --git a/tests/unit/web/test_notices.py b/tests/unit/web/test_notices.py new file mode 100644 index 00000000..7d28dba5 --- /dev/null +++ b/tests/unit/web/test_notices.py @@ -0,0 +1,274 @@ +"""`hmz.web.notices`: what is news in how the runs stand, against how they stood.""" + +from __future__ import annotations + +import threading +import time +from typing import Any + +import pytest + +from hmz.web.notices import Notices +from hmz.web.routing import Refusal + + +class Statuses: + """How the runs stand, as each look is told: changed by the test between looks.""" + + def __init__(self) -> None: + self.now: dict[str, dict[str, Any]] = {} + + def __call__(self) -> dict[str, dict[str, Any]]: + return {where: dict(status) for where, status in self.now.items()} + + def stands( + self, + workspace: str = "/w", + *, + pending: list[dict[str, Any]] | None = None, + ended: dict[str, Any] | None = None, + ) -> None: + self.now[workspace] = { + "state": "running", + "flow": "ralph", + "epic": "e1", + "task": "fix it", + "pending": pending or [], + "ended": ended, + } + + +def _ended(how: str, at: float | None = None, why: str = "") -> dict[str, Any]: + """An epic that ended as `how`, at `at` -- or now.""" + return { + "how": how, + "why": why, + "epic": "e1", + "at": time.time() if at is None else at, + "flow": "ralph", + "task": "t", + } + + +def _asked(question: str, mode: str = "ask") -> dict[str, Any]: + return {"question": question, "role": "outworlder", "text": "which?", "mode": mode} + + +@pytest.fixture +def statuses() -> Statuses: + return Statuses() + + +@pytest.fixture +def notices(statuses: Statuses) -> Notices: + return Notices(statuses) + + +def test_what_stood_at_the_start_is_no_news( + statuses: Statuses, notices: Notices +) -> None: + statuses.stands(pending=[_asked("q1")], ended=_ended("done")) + + assert notices.look() == [] + assert notices.look() == [] + assert notices.since() == [] + assert notices.last == 0 + + +@pytest.mark.parametrize( + ("how", "kind"), + [ + ("done", "done"), + ("stopped", "stopped"), + ("failed", "failed"), + ("crashed", "failed"), + ("budget", "failed"), + ("refused", "failed"), + ], +) +def test_an_epic_ending_is_a_notice_of_how( + how: str, kind: str, statuses: Statuses, notices: Notices +) -> None: + statuses.stands() + notices.look() + statuses.stands(ended=_ended(how, at=time.time() + 1, why="because")) + + (made,) = notices.look() + + assert made["kind"] == kind + assert (made["workspace"], made["epic"], made["flow"], made["task"]) == ( + "/w", + "e1", + "ralph", + "t", + ) + assert (made["role"], made["text"]) == ("", "because") + assert made["at"] > time.time() + assert made["id"] == notices.last + assert notices.look() == [] + + +def test_another_epic_ending_after_is_another_notice( + statuses: Statuses, notices: Notices +) -> None: + statuses.stands(ended=_ended("done")) + notices.look() + statuses.stands(ended=_ended("failed", at=time.time() + 1)) + + assert [one["kind"] for one in notices.look()] == ["failed"] + + +def test_a_question_is_asked_and_a_chat_waiting_is_listening( + statuses: Statuses, notices: Notices +) -> None: + statuses.stands(pending=[_asked("q1")]) + notices.look() + statuses.stands( + pending=[_asked("q1"), _asked("q2"), _asked("q3", "listen"), _asked("q4", "x")] + ) + + made = notices.look() + + assert [one["kind"] for one in made] == ["asked", "listening"] + assert (made[0]["role"], made[0]["text"], made[0]["epic"]) == ( + "outworlder", + "which?", + "e1", + ) + + +def test_a_workspace_first_seen_later_is_news( + statuses: Statuses, notices: Notices +) -> None: + notices.look() + statuses.stands("/v", ended=_ended("done")) + + assert [one["workspace"] for one in notices.look()] == ["/v"] + + +def test_an_epic_that_ended_before_the_start_is_no_news_wherever_it_is_seen( + statuses: Statuses, notices: Notices +) -> None: + notices.look() + statuses.stands("/v", ended=_ended("done", at=1.0)) + + assert notices.look() == [] + + +def test_a_crash_is_said_in_the_last_line_of_why( + statuses: Statuses, notices: Notices +) -> None: + notices.look() + statuses.stands(ended=_ended("crashed", why="Traceback:\n here\nValueError: no\n")) + + assert notices.look()[0]["text"] == "ValueError: no" + + +def test_another_host_asking_a_question_of_the_same_id_is_news( + statuses: Statuses, notices: Notices +) -> None: + statuses.stands(pending=[_asked("q1")]) + statuses.now["/w"] |= {"pid": 1, "started": "then"} + notices.look() + statuses.stands(pending=[_asked("q1")]) + statuses.now["/w"] |= {"pid": 2, "started": "now"} + + assert [one["kind"] for one in notices.look()] == ["asked"] + + +def test_a_host_that_would_not_answer_is_not_taken_for_one_that_went( + statuses: Statuses, notices: Notices +) -> None: + ended = _ended("done", at=time.time() + 1) + statuses.stands() + notices.look() + statuses.stands(ended=ended) + notices.look() + statuses.now["/w"] = {"attached": 0, "flows": [], "calls": []} + notices.look() + statuses.stands(ended=ended) + + assert notices.look() == [] + + +def test_notices_are_numbered_and_read_after_the_last_heard( + statuses: Statuses, notices: Notices +) -> None: + statuses.stands() + notices.look() + statuses.stands(pending=[_asked("q1")]) + (first,) = notices.look() + statuses.stands(pending=[_asked("q2")]) + (second,) = notices.look() + + assert second["id"] > first["id"] + assert [one["id"] for one in notices.since()] == [first["id"], second["id"]] + assert notices.since(str(first["id"])) == [second] + assert notices.since(second["id"]) == [] + with pytest.raises(Refusal): + notices.since("soon") + + +def _fails(_one: dict[str, Any]) -> None: + raise RuntimeError + + +def test_a_listener_hears_each_notice_until_it_stops( + statuses: Statuses, notices: Notices +) -> None: + heard: list[dict[str, Any]] = [] + statuses.stands() + notices.look() + unheard = notices.heard(heard.append) + notices.heard(_fails) + statuses.stands(pending=[_asked("q1")]) + notices.look() + unheard() + statuses.stands(pending=[_asked("q2")]) + notices.look() + + assert [one["kind"] for one in heard] == ["asked"] + + +def test_only_the_last_two_hundred_are_kept( + statuses: Statuses, notices: Notices +) -> None: + statuses.stands() + notices.look() + for one in range(205): + statuses.stands(pending=[_asked(f"q{one}")]) + notices.look() + + assert len(notices.since()) == 200 + + +def test_a_stream_carries_on_after_the_last_heard( + statuses: Statuses, notices: Notices +) -> None: + statuses.stands() + notices.look() + statuses.stands(pending=[_asked("q1")]) + (first,) = notices.look() + going = threading.Event() + events = notices.stream().events("0", going) + + assert next(events) == (str(first["id"]), "notice", first) + statuses.stands(pending=[_asked("q2")]) + (second,) = notices.look() + assert next(events) == (str(second["id"]), "notice", second) + notices.close() + assert list(events) == [] + + +def test_a_stream_heard_from_nothing_starts_from_now( + statuses: Statuses, notices: Notices +) -> None: + statuses.stands() + notices.look() + statuses.stands(pending=[_asked("q1")]) + notices.look() + events = notices.stream().events("", threading.Event()) + statuses.stands(pending=[_asked("q2")]) + (second,) = notices.look() + + assert next(events) == (str(second["id"]), "notice", second) diff --git a/tests/unit/web/test_notifications.py b/tests/unit/web/test_notifications.py new file mode 100644 index 00000000..cf2ce631 --- /dev/null +++ b/tests/unit/web/test_notifications.py @@ -0,0 +1,194 @@ +"""`hmz.web.notifications`: every daemon's notices, kept, filtered, read and streamed.""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +import pytest + +from hmz.web.notifications import KINDS, ROUTES, Notifications, daemons, preferences +from hmz.web.routing import Asked, Refusal + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + + +class _Notices: + """A stand-in for this machine's notices: what hears them, told each as it is said.""" + + def __init__(self) -> None: + self.hearing: list[Callable[[Any], object]] = [] + + def heard(self, callback: Callable[[Any], object]) -> None: + self.hearing.append(callback) + + def says(self, said: Any) -> None: + for one in self.hearing: + one(said) + + +def _notice(kind: str = "done", **more: Any) -> dict[str, Any]: + return {"id": "7", "kind": kind, "workspace": "/w", "epic": "e", "text": ""} | more + + +@pytest.fixture +def notices() -> _Notices: + return _Notices() + + +@pytest.fixture +def wanted() -> dict[str, Any]: + return preferences({}) + + +@pytest.fixture +def held(notices: _Notices, wanted: dict[str, Any]) -> Generator[Notifications]: + made = Notifications( + SimpleNamespace(notices=notices), wanted=lambda: wanted, named=list + ) + yield made + made.close() + + +def test_every_kind_is_told_and_shown_where_nothing_is_set() -> None: + assert preferences({}) == { + "kinds": dict.fromkeys(KINDS, True), + "system": True, + } + + +def test_a_kind_turned_off_is_off_and_anything_else_is_on() -> None: + said = {"notifications": {"kinds": {"done": False, "asked": "no"}, "system": False}} + + assert preferences(said) == { + "kinds": dict.fromkeys(KINDS, True) | {"done": False}, + "system": False, + } + + +def test_settings_not_written_as_notifications_are_every_one_wanted() -> None: + assert preferences({"notifications": ["done"]}) == preferences({}) + + +def test_ssh_daemons_are_named_as_written_and_local_is_never_one() -> None: + held = { + "daemons": [ + {"name": "gpu"}, + {"name": "local"}, + "box", + {"host": "x"}, + {"name": "b"}, + ] + } + + assert daemons(held) == ["gpu", "b"] + assert daemons({}) == [] + + +def test_this_machines_notices_are_kept_as_local_newest_first( + held: Notifications, notices: _Notices +) -> None: + notices.says(_notice("done", epic="a")) + notices.says([_notice("asked", epic="b")]) + + listed = held.listed() + + assert [(one["daemon"], one["epic"], one["kind"]) for one in listed["items"]] == [ + ("local", "b", "asked"), + ("local", "a", "done"), + ] + assert [one["id"] for one in listed["items"]] == ["2", "1"] + assert listed["unread"] == 2 + assert not any(one["read"] for one in listed["items"]) + + +def test_a_kind_not_wanted_is_never_kept( + held: Notifications, wanted: dict[str, Any] +) -> None: + wanted["kinds"]["listening"] = False + + assert held.told("gpu", _notice("listening")) is None + assert held.told("gpu", _notice("failed")) is not None + assert [one["kind"] for one in held.listed()["items"]] == ["failed"] + + +def test_only_the_last_few_hundred_are_kept(held: Notifications) -> None: + for _ in range(250): + held.told("local", _notice()) + + items = held.listed()["items"] + + assert len(items) == 200 + assert items[0]["id"] == "250" + + +def test_notifications_are_read_by_id_or_all_at_once(held: Notifications) -> None: + first = held.told("local", _notice()) + held.told("gpu", _notice()) + assert first is not None + + assert held.read([first["id"]])["unread"] == 1 + assert held.read()["unread"] == 0 + + +def test_clearing_forgets_every_one_and_the_next_counts_on( + held: Notifications, +) -> None: + held.told("local", _notice()) + + assert held.clear() == {"items": [], "unread": 0} + told = held.told("local", _notice()) + assert told is not None + assert told["id"] == "2" + + +def test_the_stream_takes_up_after_the_last_heard_then_tells_each_as_it_comes( + held: Notifications, +) -> None: + held.told("local", _notice(epic="a")) + held.told("gpu", _notice(epic="b")) + going = threading.Event() + events = held.stream().events("1", going) + + assert next(events) == ("2", "notification", held.listed()["items"][0]) + threading.Timer(0.05, held.told, args=("gpu", _notice(epic="c"))).start() + said = next(events) + assert said is not None + ident, name, data = said + + assert (ident, name, data["epic"], data["daemon"]) == ( + "3", + "notification", + "c", + "gpu", + ) + + +def test_a_stream_heard_of_an_earlier_interface_starts_over( + held: Notifications, +) -> None: + held.told("local", _notice()) + + events = held.stream().events("90", threading.Event()) + + said = next(events) + assert said is not None + assert said[0] == "1" + + +def test_a_page_marks_read_by_ids_or_all_and_nothing_else( + held: Notifications, +) -> None: + (read,) = [one for one in ROUTES if one.path == "/api/notifications/read"] + site = SimpleNamespace(notifications=held) + held.told("local", _notice()) + held.told("local", _notice()) + + assert read.answers(Asked(site, body={"ids": [1]}))["unread"] == 1 + assert read.answers(Asked(site, body={"all": True}))["unread"] == 0 + with pytest.raises(Refusal): + read.answers(Asked(site, body={"ids": "1"})) + with pytest.raises(Refusal, match="not kept"): + read.answers(Asked(SimpleNamespace(), body={"all": True})) diff --git a/tests/unit/web/test_remote.py b/tests/unit/web/test_remote.py new file mode 100644 index 00000000..2ad215c7 --- /dev/null +++ b/tests/unit/web/test_remote.py @@ -0,0 +1,27 @@ +"""What a host says it has, and what a request to its interface carries to be let in.""" + +from __future__ import annotations + +from hmz.web.remote import headers, said + + +def test_a_probe_is_read_by_what_each_line_says() -> None: + assert said( + [ + "Warning: Permanently added 'box' to the list of known hosts.", + "hmz: 0.5.0rc1", + "daemon: running", + "something: else", + ] + ) == {"hmz": "0.5.0rc1", "daemon": "running"} + assert said(["hmz: ", "daemon: none"]) == {"hmz": "", "daemon": "none"} + assert said([]) == {} + + +def test_a_request_there_is_its_own_page_s() -> None: + assert headers(8123, "k3y") == { + "Host": "127.0.0.1:8123", + "Origin": "http://127.0.0.1:8123", + "Sec-Fetch-Site": "same-origin", + "Cookie": "hmz-8123=k3y", + }