From ad96cf47db05529216e4f9eaf4701427ee7bd077 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:46:59 +0800 Subject: [PATCH 01/16] docs(specs): spec multiple daemons and notifications for web and desktop web: daemons kept in Local's settings (Local plus SSH hosts, added or imported), reached by running `hmz web --attached` over ssh with the remote hmz installed at this version unless it holds running epics, proxied under /d//api/; the daemon bar and picker; only the current daemon shown, carried in every address; Settings grouped as App and Daemon, with a daemon picked per page and Batch apply; notices per machine and notifications gathered across daemons, filtered by kind, with a bell, cards and system notifications. runtime/doing: Host.status carries pending questions, the last end, and the epic and task. desktop: the page may invoke `notify` alone, shown as a native notification that opens its own-origin address. Co-Authored-By: Claude Opus 5.5 --- specs/cli.md | 3 +- specs/daemon.md | 2 +- specs/desktop.md | 15 ++++-- specs/runtime/doing.md | 6 ++- specs/web.md | 106 +++++++++++++++++++++++++++++++++++++++-- 5 files changed, 121 insertions(+), 11 deletions(-) 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 c8c71c6d..3228bfba 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 @@ -373,6 +373,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. From 9a4a54e2f850731fbd72490ffc05dd626652a4f4 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:47:48 +0800 Subject: [PATCH 02/16] docs: daemons, per-daemon settings and notifications in the web page and app Document Local and ssh daemons (adding, importing from ~/.ssh/config, connecting, auto-install of the matching hmz, never under running epics), the daemon bar and picker, #/@/ addresses, the App and Daemon settings groups with the daemon selector and Batch apply, and global notifications: kinds, bell, menu, toasts, browser and macOS notifications. Co-Authored-By: Claude Opus 5.5 --- docs/features/surfaces.md | 5 + docs/user/desktop.md | 22 +++++ docs/user/settings.md | 4 + docs/user/web.md | 190 +++++++++++++++++++++++++++++++++----- 4 files changed, 197 insertions(+), 24 deletions(-) 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/user/desktop.md b/docs/user/desktop.md index 75f28a5d..8a6a6ffa 100644 --- a/docs/user/desktop.md +++ b/docs/user/desktop.md @@ -57,6 +57,10 @@ 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 notifies as macOS does.** See [Notifications](#notifications). - **It updates itself.** See [Updates](#updates). ## Opening a `.hmzepic` {#opening-a-hmzepic} @@ -70,6 +74,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. +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 +159,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 7b3598a5..2290d514 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,32 @@ 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: +The same pages as [`/settings`](/user/settings), and what the epics spent, in two groups: **App**, +what this page and this machine keep for every daemon, and **Daemon**, what one daemon keeps. -| 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 | -| **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 | +| Group | Page | What is there | +| --- | --- | --- | +| App | **Themes** | The [themes](#themes) of this browser, and which two **Auto** follows your system with | +| App | **Daemons** | Local and every ssh host the page reaches: add, edit, import, connect or remove one. See [Daemons](#daemons) | +| App | **Notifications** | Which [notifications](#notifications) appear, and whether they are also system notifications | +| Daemon | **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) | +| Daemon | **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 | +| Daemon | **Fallback** | Each [step](/user/settings#fallback): where a turn goes when a place cannot take it, and how it is tried again | +| Daemon | **Runtimes** | Every runtime: add one from its backend's fields, change, check or remove it, or bring hosts in from your ssh config | +| Daemon | **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 | +| Daemon | **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 | +| Daemon | **Usage** | What the epics spent, of every directory or of one, by day and by flow, looking back 7, 30, 90 or 365 days | + +The **Daemon** group opens on the daemon the page shows, and a selector at its top picks +another: it changes only which daemon the page reads and saves, not the one the rest of the +page shows. So the accounts of `devbox` are set up from here without leaving Local's epics. + +**Batch apply**, on each Daemon page, copies what that page holds to other daemons at once: +tick the daemons, and it saves the page on each and says, daemon by daemon, whether it took. +It never copies a secret -- an account whose way in holds a value, such as a key, is left out, +and the page says so; make it on each daemon by hand. 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 +365,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 @@ -426,6 +547,26 @@ The host holding the runs went: it was closed, or it let this window go. The pag runs again by itself a moment later, starting a host where none is, and the foot of the sidebar 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 +576,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. From 9120680a4fc6fa078ac6314326bba11feeb26b3d Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:55:26 +0800 Subject: [PATCH 03/16] feat(web): turn the sidebar's foot into a daemon bar and picker The bar names the daemon the page is of, its host, how it stands and how many epics are live on it. Its picker lists GET /api/daemons (Local alone on a 404), switches by the address's @, offers Connect for a failed daemon and Manage for Settings -> Daemons, and keeps Update, Import, Settings, Usage and Download. Co-Authored-By: Claude Opus 5.5 --- docs/reference/cli.md | 2 +- docs/user/backups.md | 2 +- docs/user/web.md | 14 ++- src/hmz/web/static/sidebar.js | 145 +++++++++++++++++++---- src/hmz/web/static/style.css | 40 +++++++ tests/integration/test_web_daemon_bar.py | 114 ++++++++++++++++++ tests/integration/test_web_page.py | 22 ++-- tests/integration/test_web_pointer.py | 6 +- 8 files changed, 301 insertions(+), 44 deletions(-) create mode 100644 tests/integration/test_web_daemon_bar.py 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/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/web.md b/docs/user/web.md index 7b3598a5..f7ee8235 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -65,9 +65,13 @@ 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 +The foot of the sidebar is the daemon bar: the daemon the page is of -- **Local**, or an SSH +daemon by its name -- its host, a dot for how it stands (up, connecting, installing, failed or +not connected) and how many epics are `live` on it, or `idle`. Click it for the daemon picker: +every daemon with how it stands, a click switching the page to it; **Connect** under one that +failed; **Manage**, which opens **Settings → Daemons**; then **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 the picker 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 sidebar is a drawer behind the button in the corner. @@ -212,7 +216,7 @@ 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 +Somebody sent you a `.hmzepic`? Press **Import** in **Search epics**, or in the daemon picker 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 once it is in. One you already have is refused, with **Open** to go to it. @@ -423,7 +427,7 @@ 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 way in is marked `(at a terminal)` diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index ce77d3bf..df8468f0 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -1,8 +1,10 @@ // 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 * as api from './api.js' import { address, get, poll, post, refresh } from './api.js' import { ask } from './held.js' import { pick } from './importing.js' @@ -26,7 +28,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,25 +36,7 @@ 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( @@ -72,7 +55,7 @@ export function mountSidebar(root, context, where) { h('a', { href: '#/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 +105,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: [] }) @@ -349,6 +332,120 @@ 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.switchDaemon) return api.switchDaemon(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) => { + if (Array.isArray(answer)) daemons = answer.some((one) => one.name === 'local') ? answer : [local, ...answer] + 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: '#/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: '#/settings/general', close }), + choice({ label: 'Usage', glyph: 'chart', href: '#/settings/usage', close }), + choice({ label: 'Download', glyph: 'download', href: '#/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..bf28b75f 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,43 @@ 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: none; + max-width: 70%; +} + +.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); +} diff --git a/tests/integration/test_web_daemon_bar.py b/tests/integration/test_web_daemon_bar.py new file mode 100644 index 00000000..3c647bb0 --- /dev/null +++ b/tests/integration/test_web_daemon_bar.py @@ -0,0 +1,114 @@ +"""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 ENGINES, opened, served + +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".*#/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/integration/test_web_page.py b/tests/integration/test_web_page.py index 2dcdcf76..a51a6acd 100644 --- a/tests/integration/test_web_page.py +++ b/tests/integration/test_web_page.py @@ -406,10 +406,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") @@ -581,11 +581,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( @@ -645,7 +647,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" ) @@ -833,7 +835,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}']") @@ -995,9 +997,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/integration/test_web_pointer.py b/tests/integration/test_web_pointer.py index 2951a3f2..f3d7f7cd 100644 --- a/tests/integration/test_web_pointer.py +++ b/tests/integration/test_web_pointer.py @@ -230,9 +230,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. From 72569c84bcd669304841db47717c6ad73553a3d4 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:56:35 +0800 Subject: [PATCH 04/16] feat(web): daemon registry API under /api/daemons Keep the ssh daemons a page may switch between in settings.yaml under `daemons`, checked as an ssh runtime is: Local first and implicit, then each one added by hand or imported from the ssh config. Each carries how it stands from `site.remotes` where the site has one. Co-Authored-By: Claude Opus 5.5 --- docs/reference/web.md | 5 + src/hmz/web/daemons.py | 294 ++++++++++++++++++++++++++ src/hmz/web/site.py | 2 + tests/integration/test_web_daemons.py | 124 +++++++++++ tests/unit/web/test_daemons.py | 219 +++++++++++++++++++ 5 files changed, 644 insertions(+) create mode 100644 src/hmz/web/daemons.py create mode 100644 tests/integration/test_web_daemons.py create mode 100644 tests/unit/web/test_daemons.py diff --git a/docs/reference/web.md b/docs/reference/web.md index e4311393..d5a8e61e 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -274,6 +274,11 @@ 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 | An account is listed by the names of the variables it sets (`sets`), never by a value. 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/site.py b/src/hmz/web/site.py index e6204457..4aad7b1a 100644 --- a/src/hmz/web/site.py +++ b/src/hmz/web/site.py @@ -39,6 +39,7 @@ from . import ( accounts, + daemons, fallbacks, flows, held, @@ -68,6 +69,7 @@ *accounts.ROUTES, *fallbacks.ROUTES, *runtimes.ROUTES, + *daemons.ROUTES, *paths.ROUTES, *places.ROUTES, *verses.ROUTES, 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/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 From f995344f547770f57eb3c10a582eaa0b9a6d2716 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:56:43 +0800 Subject: [PATCH 05/16] feat(web): one notification feed of every daemon's notices Serve /api/notifications, its stream, read, clear and settings on the local interface. Notices are heard from site.notices and followed off each reached SSH daemon's /api/notices/stream, tagged with the daemon, filtered by the notifications kinds in settings.yaml, and the last 200 kept. Co-Authored-By: Claude Opus 5.5 --- docs/reference/settings.md | 4 + docs/reference/web.md | 21 ++ src/hmz/web/__init__.py | 4 + src/hmz/web/notifications.py | 391 ++++++++++++++++++++ src/hmz/web/site.py | 5 + tests/integration/test_web_notifications.py | 267 +++++++++++++ tests/unit/web/test_notifications.py | 194 ++++++++++ 7 files changed, 886 insertions(+) create mode 100644 src/hmz/web/notifications.py create mode 100644 tests/integration/test_web_notifications.py create mode 100644 tests/unit/web/test_notifications.py diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 2c7b1d6c..af163419 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -46,6 +46,9 @@ enable_sentry: false # this machine details: true # this machine btw: claude/claude-opus-5:high # this machine every_epic: true # this machine +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 +79,7 @@ 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 | +| `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 e4311393..db73c0f4 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -277,6 +277,27 @@ Every `error` but the last is the runtime's own sentence, e.g. An account is listed by the names of the variables it sets (`sets`), never by a value. +### 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/src/hmz/web/__init__.py b/src/hmz/web/__init__.py index 5c6d7197..c24f1bbd 100644 --- a/src/hmz/web/__init__.py +++ b/src/hmz/web/__init__.py @@ -59,6 +59,7 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]: from hmz.coganchor import releases + from .notifications import Notifications from .places import Places from .runs import Runs from .site import Site @@ -73,6 +74,9 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]: # 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'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) yield site diff --git a/src/hmz/web/notifications.py b/src/hmz/web/notifications.py new file mode 100644 index 00000000..ceb19398 --- /dev/null +++ b/src/hmz/web/notifications.py @@ -0,0 +1,391 @@ +"""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. + heard = int(last) if last.isdigit() else told + after = heard if heard <= told else 0 + 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/site.py b/src/hmz/web/site.py index e6204457..39292480 100644 --- a/src/hmz/web/site.py +++ b/src/hmz/web/site.py @@ -42,6 +42,7 @@ fallbacks, flows, held, + notifications, paths, places, runs, @@ -54,6 +55,7 @@ if TYPE_CHECKING: from collections.abc import Generator + from .notifications import Notifications from .places import Places from .runs import Runs @@ -71,6 +73,7 @@ *paths.ROUTES, *places.ROUTES, *verses.ROUTES, + *notifications.ROUTES, ) #: How many ports any would do for are tried before one free on both loopbacks is given up on. @@ -152,6 +155,8 @@ def __init__(self, port: int, *, places: Places, runs: Runs) -> None: self.places = places self.hmz: Any = places.hmz self.runs = runs + #: 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) 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/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})) From 1adc88ed44692a4cf11277c3a09f36d3b8b78fbd Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:56:52 +0800 Subject: [PATCH 06/16] feat(web): Daemons and Notifications settings pages Settings gains a Daemons page -- Local and every SSH daemon with its state, an SSH form a part at a time, import from the SSH config, connect, and remove asked first -- and a Notifications page with the kinds, the system toggle and the browser's Allow. Both say Unavailable where the server does not serve their endpoints yet. Co-Authored-By: Claude Opus 5.5 --- docs/reference/web.md | 2 +- docs/user/web.md | 2 + src/hmz/web/static/style.css | 62 ++++ src/hmz/web/static/views/settings.js | 2 + src/hmz/web/static/views/settings/daemons.js | 267 ++++++++++++++++++ .../static/views/settings/notifications.js | 109 +++++++ tests/integration/test_web_settings_pages.py | 249 ++++++++++++++++ 7 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 src/hmz/web/static/views/settings/daemons.js create mode 100644 src/hmz/web/static/views/settings/notifications.js create mode 100644 tests/integration/test_web_settings_pages.py diff --git a/docs/reference/web.md b/docs/reference/web.md index e4311393..2f90182a 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -143,7 +143,7 @@ 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. | 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 diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..f045db78 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -259,6 +259,8 @@ The same pages as [`/settings`](/user/settings), in the same order, and what the | **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 | 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 diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe47..5a8af8b1 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,65 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* 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; + } +} diff --git a/src/hmz/web/static/views/settings.js b/src/hmz/web/static/views/settings.js index b8480585..cce510d8 100644 --- a/src/hmz/web/static/views/settings.js +++ b/src/hmz/web/static/views/settings.js @@ -18,6 +18,8 @@ const PAGES = { 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') }, + daemons: { label: 'Daemons', about: 'Machines this page can show', load: () => import('./settings/daemons.js') }, + notifications: { label: 'Notifications', about: 'What is told, of every daemon', load: () => import('./settings/notifications.js') }, } export function mount(root, { parts, query }) { 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..27682ede --- /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 entry = { + ...(editing || {}), + name: name.value.trim(), + host: host.value.trim(), + user: user.value.trim(), + port: port.value === '' ? null : Number(port.value), + identity_file: key.value(), + proxy_jump: jump.value.trim(), + options: options.read(), + } + delete entry.state + delete entry.detail + delete entry.version + await post('/api/daemons', entry) + 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/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/tests/integration/test_web_settings_pages.py b/tests/integration/test_web_settings_pages.py new file mode 100644 index 00000000..12ad7baa --- /dev/null +++ b/tests/integration/test_web_settings_pages.py @@ -0,0 +1,249 @@ +"""`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 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 ENGINES, go, opened, served + +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(body) + 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", + "host": "lab.example", + "user": "ann", + "port": 2222, + "identity_file": "~/.ssh/lab", + "proxy_jump": "me@jump", + "options": [["ServerAliveInterval", "30"]], + }, + ) + + +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: + with served() as site, opened(site, engine=engine) 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 From 501bf19335c38821699a76a5312b5009219fabb3 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:57:21 +0800 Subject: [PATCH 07/16] feat(web): scope the page to the daemon its address names The address names the daemon first, #/@/, and Local where it names none. api.js asks everything of that daemon under /d//api/, save /api/daemons and /api/notifications, keeps @ in every link through hash(), and on a switch clears what polls read, closes the held streams and fires hmz:daemon, on which the view and the sidebar's lists are drawn again. The server answers /d/local/api/ as /api/. Co-Authored-By: Claude Opus 5.5 --- docs/reference/web.md | 9 ++ docs/user/web.md | 6 +- src/hmz/web/site.py | 3 + src/hmz/web/static/api.js | 76 ++++++++- src/hmz/web/static/app.js | 43 +++-- src/hmz/web/static/graph.js | 3 +- src/hmz/web/static/held.js | 15 +- src/hmz/web/static/importing.js | 9 +- src/hmz/web/static/pickers.js | 20 ++- src/hmz/web/static/places.js | 4 +- src/hmz/web/static/sidebar.js | 61 ++++--- src/hmz/web/static/views/download.js | 6 +- src/hmz/web/static/views/epic.js | 24 +-- src/hmz/web/static/views/exomyth/view.js | 4 +- src/hmz/web/static/views/flows.js | 6 +- src/hmz/web/static/views/import.js | 6 +- src/hmz/web/static/views/new.js | 4 +- src/hmz/web/static/views/search.js | 4 +- src/hmz/web/static/views/settings.js | 3 +- src/hmz/web/static/views/settings/runtimes.js | 3 +- src/hmz/web/static/views/settings/themes.js | 19 +-- src/hmz/web/static/views/settings/usage.js | 4 +- tests/integration/test_web_scoping.py | 150 ++++++++++++++++++ 23 files changed, 391 insertions(+), 91 deletions(-) create mode 100644 tests/integration/test_web_scoping.py diff --git a/docs/reference/web.md b/docs/reference/web.md index e4311393..c8122b08 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -145,6 +145,15 @@ Each view is an address after `#`, so it can be kept and opened again: | `#/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. | +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 folded away as `hmz-sidebar`, a side panel closed as `hmz-panel`, which epics are opened out diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..fff5912c 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -71,7 +71,11 @@ its menu opens **Import**, **Settings**, **Usage** and **Download**, which downl **Update**, which copies `hmz update` to run in a terminal. ⇧⌘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. An address names the +daemon it is of first, `#/@gpu/new`, and the page shows only that daemon's epics, workspaces +and settings; every link keeps it, and going to another daemon's address draws everything again +of that one. An address naming no daemon is Local's. See +[The page](/reference/web#page). **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, diff --git a/src/hmz/web/site.py b/src/hmz/web/site.py index e6204457..aaec3087 100644 --- a/src/hmz/web/site.py +++ b/src/hmz/web/site.py @@ -282,6 +282,9 @@ 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 method != "GET": 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/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/pickers.js b/src/hmz/web/static/pickers.js index c875a906..7572bfd7 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' @@ -20,11 +20,23 @@ let reading = '' export async function readBackends(workspace = reading) { reading = workspace - backends = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`) + const of = daemon() + const read = await get(`/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}` @@ -151,7 +163,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 +261,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..87594312 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) { diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index ce77d3bf..771672b7 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -3,7 +3,7 @@ // 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. -import { address, get, poll, post, refresh } 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' @@ -48,9 +48,9 @@ export function mountSidebar(root, context, where) { (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 }), + 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: 'Machine' }, ) @@ -60,16 +60,16 @@ export function mountSidebar(root, context, where) { 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')), 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), @@ -149,20 +149,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 +185,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 +224,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,25 +314,38 @@ 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)}`) const resumes = run.resumable && !run.held && run.how !== 'done' return [ { head: run.task.split('\n')[0] }, { 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') }, - run.held ? null : { label: 'Export', glyph: 'download', act: () => (location.href = `/api/runs/${encodeURIComponent(run.name)}/bundle`) }, + run.held ? 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, 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 90084c60..54d4b43b 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..00b7ab86 100644 --- a/src/hmz/web/static/views/settings.js +++ b/src/hmz/web/static/views/settings.js @@ -4,6 +4,7 @@ // 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. +import { hash } from '../api.js' import { shut } from '../app.js' import { fill, h, iconButton, problem } from '../dom.js' import { dragged } from '../shell.js' @@ -34,7 +35,7 @@ export function mount(root, { parts, query }) { '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)), + Object.entries(PAGES).map(([key, one]) => h('a', { href: hash(`/settings/${key}`), 'aria-current': key === name ? 'page' : null }, one.label)), ), ), h( diff --git a/src/hmz/web/static/views/settings/runtimes.js b/src/hmz/web/static/views/settings/runtimes.js index a27ad2f4..a84d5e96 100644 --- a/src/hmz/web/static/views/settings/runtimes.js +++ b/src/hmz/web/static/views/settings/runtimes.js @@ -3,7 +3,7 @@ // 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' @@ -196,6 +196,7 @@ function labelOf(key) { /** The SSH hosts your config names, read once, for the fields that name one. */ let hosts = null +onDaemon(() => (hosts = null)) function sshHosts() { hosts ||= get('/api/runtimes/hosts').then((answer) => answer.hosts.map((one) => one.alias), () => []) return hosts 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..dfd9519d 100644 --- a/src/hmz/web/static/views/settings/usage.js +++ b/src/hmz/web/static/views/settings/usage.js @@ -2,7 +2,7 @@ // 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, hash, poll } from '../../api.js' import { stays } from '../../app.js' import { badge, fill, flowName, h, problem } from '../../dom.js' import { workspaces } from '../../places.js' @@ -121,7 +121,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/tests/integration/test_web_scoping.py b/tests/integration/test_web_scoping.py new file mode 100644 index 00000000..d681a345 --- /dev/null +++ b/tests/integration/test_web_scoping.py @@ -0,0 +1,150 @@ +"""`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 ENGINES, go, opened, served + +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 + if "/api/" in 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" + ) From e88715dab2c9f6c3c3103035721ec7dce2005ea2 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:58:40 +0800 Subject: [PATCH 08/16] feat(desktop): post native notifications for hmz web's page The page in the app can call `notify` with {title, body, address}. The app posts it through UNUserNotificationCenter, asking for permission the first time. Clicking the notification brings the window forward. If the address is a `#/...` hash, the app opens that hash on hmz web's own origin and goes nowhere else. A click that launches the app opens the hash once the page is there. - The app's commands get an ACL manifest. The remote page (http://127.0.0.1:*) is granted only `notify`, next to drag and zoom. The app's own page keeps status, retry, keep and update_now. - No new crates: objc2, objc2-foundation, block2 and objc2-user-notifications are already in the lock. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 +- desktop/src-tauri/Cargo.lock | 6 + desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/build.rs | 8 +- desktop/src-tauri/capabilities/default.json | 11 +- desktop/src-tauri/capabilities/page.json | 4 +- desktop/src-tauri/src/lib.rs | 20 +- desktop/src-tauri/src/notify.rs | 262 ++++++++++++++++++++ docs/user/desktop.md | 13 + 9 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 desktop/src-tauri/src/notify.rs 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/user/desktop.md b/docs/user/desktop.md index 75f28a5d..8ab5156d 100644 --- a/docs/user/desktop.md +++ b/docs/user/desktop.md @@ -58,6 +58,19 @@ The app is a window, not a second humanize. It runs `hmz` as - **Links to elsewhere open in your browser**, and an export is saved to **Downloads** and shown in the Finder. - **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). + +## Notifications {#notifications} + +The page's [notifications](/user/web#notifications) come from the app itself, so they +look and stay as any Mac app's do. The first one asks whether humanize may send them: choose +**Allow**. To change that later, open **System Settings → Notifications → humanize**. + +Clicking one brings the window forward, on what it is about: the epic that ended, or the daemon +whose flow asked. Which kinds you are told of is set on the page, in **Settings → +Notifications**. ## Opening a `.hmzepic` {#opening-a-hmzepic} From e570ac0e152f044174bbbce43d4c7ff8ee01647d Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:58:50 +0800 Subject: [PATCH 09/16] feat(web): a notification centre of every daemon's notifications A bell in the sidebar's top row with the unread count, a menu of the history newest first (kind, daemon, flow and what it said, how long ago) with Mark read and Clear, a card at the bottom right for each one heard on the stream, and -- while the page is hidden or unfocused -- the browser's Notification or the macOS app's `notify` command. Clicking any of them reads it and opens the epic going or the epic that ended. Built on the global /api/notifications endpoints; where the server has none, the bell is hidden. Co-Authored-By: Claude Opus 5.5 --- docs/user/web.md | 17 ++ src/hmz/web/static/dom.js | 1 + src/hmz/web/static/notifications.js | 198 +++++++++++++++ src/hmz/web/static/sidebar.js | 2 + src/hmz/web/static/style.css | 108 +++++++++ tests/integration/doubles_web.py | 4 + .../test_web_notification_centre.py | 226 ++++++++++++++++++ 7 files changed, 556 insertions(+) create mode 100644 src/hmz/web/static/notifications.js create mode 100644 tests/integration/test_web_notification_centre.py diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..9884b040 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -73,6 +73,23 @@ 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. +### Notifications {#notifications} + +The bell at the top of the sidebar gathers what needs you, from every daemon at once rather +than only the one shown: an epic **Done**, **Failed** or **Stopped**, a **Question** a flow put +to a person, and a **Chat turn** -- a `chat` waiting for your next line. Its count is how many +you have not read. Its menu lists them newest first, each with its daemon (**Local**, or the +daemon's name), its flow and what it said, and how long ago; **Mark read** reads them all and +**Clear** empties the list. Click one to read it and open it: a question or a chat turn opens the +epic going in its directory, and an ended epic opens that epic. + +Each one heard while the page is open also shows a card at the bottom right for a few seconds; +click it to open what it is about. While the page is hidden or another window is in front, the +system shows it too -- the browser's own notification, once you have let the page send them, +or in [the app](/user/desktop) the system's own -- and clicking it brings the page back on it. +Which kinds are notified, and whether the system shows them, is set under **Settings → +Notifications**. + **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, export, stop), an agent or a session, a node on the graph, a flow or flowverse card, a search 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/notifications.js b/src/hmz/web/static/notifications.js new file mode 100644 index 00000000..1a7ecd19 --- /dev/null +++ b/src/hmz/web/static/notifications.js @@ -0,0 +1,198 @@ +// 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() +/** Whether the system is let show them, as the settings said when last read. */ +let settings = Promise.resolve(null) + +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 + readSettings() + 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; the settings as it is left. + stream.addEventListener('open', () => reread().catch(() => {})) + document.addEventListener('visibilitychange', () => (document.hidden ? readSettings() : reread().catch(() => {}))) + addEventListener('blur', readSettings) +} + +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 readSettings() { + settings = get('/api/notifications/settings').catch(() => null) +} + +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 + const said = await settings + 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/sidebar.js b/src/hmz/web/static/sidebar.js index ce77d3bf..e803f8ef 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -9,6 +9,7 @@ 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' @@ -61,6 +62,7 @@ export function mountSidebar(root, context, where) { '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')), + bell(), iconButton('sidebar', 'Hide sidebar', { class: 'side-fold', onclick: () => toggleSidebar(false), 'aria-controls': 'sidebar' }), ), ), diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe47..b4231590 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,111 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* 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/tests/integration/doubles_web.py b/tests/integration/doubles_web.py index 5caa1673..c7c46ba0 100644 --- a/tests/integration/doubles_web.py +++ b/tests/integration/doubles_web.py @@ -85,6 +85,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. @@ -96,6 +97,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. @@ -120,6 +122,8 @@ def opened( if init: context.add_init_script(init) page = context.new_page() + if routed: + routed(page) failed: list[str] = [] page.on("pageerror", lambda error: failed.append(str(error))) # A refusal is the server answering, which the page says where it happened; what diff --git a/tests/integration/test_web_notification_centre.py b/tests/integration/test_web_notification_centre.py new file mode 100644 index 00000000..f2f218ae --- /dev/null +++ b/tests/integration/test_web_notification_centre.py @@ -0,0 +1,226 @@ +"""`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 +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 ENGINES, opened, served + +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: + with served() as site, opened(site, engine=engine) as page: + expect(page.locator("[data-hmz=sidebar] .brand")).to_be_visible() + expect(page.locator("[data-hmz=notifications]")).to_be_hidden() + + +@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", + } + ] From 01b354392704d22cd021ce9b4a983def9b358ed6 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:01:20 +0800 Subject: [PATCH 10/16] feat(web): set daemon settings per daemon, with batch apply Settings now has two groups. App holds this page's own settings (Themes, and Daemons and Notifications once their pages exist). Daemon holds the rest, read from and written to the daemon picked above them: the one on screen by default, kept in the address as ?daemon=. General, Fallback, Runtimes and Backup gain Batch apply, which sends what the page holds to each daemon ticked and says what each answered. Accounts say their secrets stay put. Co-Authored-By: Claude Opus 5.5 --- docs/user/web.md | 23 +- src/hmz/web/static/paths.js | 7 +- src/hmz/web/static/pickers.js | 11 +- src/hmz/web/static/style.css | 67 +++++ src/hmz/web/static/views/settings.js | 240 +++++++++++++++--- src/hmz/web/static/views/settings/accounts.js | 17 +- src/hmz/web/static/views/settings/backup.js | 8 +- src/hmz/web/static/views/settings/fallback.js | 11 +- src/hmz/web/static/views/settings/general.js | 23 +- src/hmz/web/static/views/settings/runtimes.js | 35 +-- src/hmz/web/static/views/settings/usage.js | 11 +- .../web/static/views/settings/workspace.js | 11 +- .../integration/test_web_settings_daemons.py | 174 +++++++++++++ 13 files changed, 541 insertions(+), 97 deletions(-) create mode 100644 tests/integration/test_web_settings_daemons.py diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..e9612dcd 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -247,12 +247,17 @@ conversation. ### 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 | +| **Daemons** | The daemons this page reaches: **Local**, and each SSH host added or imported | +| **Notifications** | Which notifications appear, from every daemon, and whether the system shows them | +| **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 | @@ -260,6 +265,20 @@ The same pages as [`/settings`](/user/settings), in the same order, and what the | **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 | +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 offered as `(at a terminal)` and made from `/settings` in `hmz`. 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..2c124775 100644 --- a/src/hmz/web/static/pickers.js +++ b/src/hmz/web/static/pickers.js @@ -17,10 +17,13 @@ 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 + backends = await get(`${from}/api/backends?workspace=${encodeURIComponent(workspace)}`) for (const one of told) one() return backends } @@ -31,12 +34,12 @@ function ask(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) }), ) } diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe47..99cfb863 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,70 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* 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; + } +} diff --git a/src/hmz/web/static/views/settings.js b/src/hmz/web/static/views/settings.js index b8480585..25f8e10d 100644 --- a/src/hmz/web/static/views/settings.js +++ b/src/hmz/web/static/views/settings.js @@ -1,59 +1,241 @@ // 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 { 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') }, + // Each only where its module is there to load. + daemons: { group: 'app', optional: true, label: 'Daemons', about: 'Machines this page reaches', load: () => import('./settings/daemons.js') }, + notifications: { group: 'app', optional: true, 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) => `#/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, hidden: Boolean(one.optional) && key !== name }, 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, ), ) + + // Each optional page offered once it is known to load. + for (const [key, one] of Object.entries(PAGES)) { + if (one.optional && key !== name) one.load().then(() => (links[key].hidden = false), () => {}) + } + + 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/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/runtimes.js b/src/hmz/web/static/views/settings/runtimes.js index a27ad2f4..95973f3d 100644 --- a/src/hmz/web/static/views/settings/runtimes.js +++ b/src/hmz/web/static/views/settings/runtimes.js @@ -9,7 +9,7 @@ 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,11 @@ 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() +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 +208,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 +245,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/usage.js b/src/hmz/web/static/views/settings/usage.js index dfa5001a..9da627d3 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, 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) { 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/test_web_settings_daemons.py b/tests/integration/test_web_settings_daemons.py new file mode 100644 index 00000000..c8385990 --- /dev/null +++ b/tests/integration/test_web_settings_daemons.py @@ -0,0 +1,174 @@ +"""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 ENGINES, go, opened, served + +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() + 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 From 1a4086a212556b371055bf5805bac9e8436a3075 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:01:47 +0800 Subject: [PATCH 11/16] feat(web): per-machine notices from host status A host's status now says the questions it waits on (`pending`), how the last run to end ended (`ended`), and the run's `epic` and `task`. `hmz web` polls every workspace's host status every 2 s, without attaching, and turns what changed into notices (done, failed, stopped, asked, listening), served at `GET /api/notices?after=` and as `notice` events on `GET /api/notices/stream`. Co-Authored-By: Claude Opus 5.5 --- docs/reference/daemon.md | 3 + docs/reference/web.md | 24 ++ src/hmz/runtime/doing/hosting.py | 36 ++- src/hmz/web/__init__.py | 8 +- src/hmz/web/notices.py | 279 +++++++++++++++++++++++ src/hmz/web/places.py | 43 +++- src/hmz/web/site.py | 9 +- tests/integration/test_web_notices.py | 146 ++++++++++++ tests/unit/runtime/doing/test_hosting.py | 51 +++++ tests/unit/web/test_notices.py | 274 ++++++++++++++++++++++ 10 files changed, 860 insertions(+), 13 deletions(-) create mode 100644 src/hmz/web/notices.py create mode 100644 tests/integration/test_web_notices.py create mode 100644 tests/unit/web/test_notices.py 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/web.md b/docs/reference/web.md index e4311393..f5fbb456 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -277,6 +277,30 @@ Every `error` but the last is the runtime's own sentence, e.g. 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 | + ## The stream {#stream} `GET /api/held/stream` answers `text/event-stream`, starting with `retry: 2000`. Each event is 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..9bddeef2 100644 --- a/src/hmz/web/__init__.py +++ b/src/hmz/web/__init__.py @@ -59,6 +59,7 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]: from hmz.coganchor import releases + from .notices import Notices from .places import Places from .runs import Runs from .site import Site @@ -67,7 +68,8 @@ 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. @@ -75,6 +77,10 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]: held_open.callback(site.server_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/notices.py b/src/hmz/web/notices.py new file mode 100644 index 00000000..af4988c8 --- /dev/null +++ b/src/hmz/web/notices.py @@ -0,0 +1,279 @@ +"""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/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/site.py b/src/hmz/web/site.py index e6204457..ee416075 100644 --- a/src/hmz/web/site.py +++ b/src/hmz/web/site.py @@ -42,6 +42,7 @@ fallbacks, flows, held, + notices, paths, places, runs, @@ -54,6 +55,7 @@ if TYPE_CHECKING: from collections.abc import Generator + from .notices import Notices from .places import Places from .runs import Runs @@ -71,6 +73,7 @@ *paths.ROUTES, *places.ROUTES, *verses.ROUTES, + *notices.ROUTES, ) #: How many ports any would do for are tried before one free on both loopbacks is given up on. @@ -131,12 +134,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 +158,7 @@ def __init__(self, port: int, *, places: Places, runs: Runs) -> None: self.places = places self.hmz: Any = places.hmz self.runs = runs + self.notices = notices #: Set as the server stops, which is what ends every stream it is sending. self.going = threading.Event() self._key = secrets.token_urlsafe(32) diff --git a/tests/integration/test_web_notices.py b/tests/integration/test_web_notices.py new file mode 100644 index 00000000..ad1cf26e --- /dev/null +++ b/tests/integration/test_web_notices.py @@ -0,0 +1,146 @@ +"""`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/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_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) From 30fbc1fb805ca35ae2f5156bb3a768f5ab60862b Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:05:56 +0800 Subject: [PATCH 12/16] feat(web): reach daemons on other machines over ssh, installing hmz there Each daemon under `daemons` in settings.yaml is reached by running `hmz web --attached` over ssh, with a loopback port forwarded to it, and proxied at /d//api/... (SSE and uploads streamed) with its own key; /d/local/api/... is this machine. A missing or other hmz there is installed at this version with uv tool install, never while its daemon may hold running epics. Adds /api/daemons/{name}/state and /connect. Co-Authored-By: Claude Opus 5.5 --- src/hmz/web/__init__.py | 4 + src/hmz/web/proxy.py | 133 ++++++ src/hmz/web/remote.py | 677 +++++++++++++++++++++++++++ src/hmz/web/site.py | 39 +- tests/integration/doubles_ssh.py | 216 +++++++++ tests/integration/test_web_remote.py | 270 +++++++++++ tests/unit/web/test_remote.py | 27 ++ 7 files changed, 1365 insertions(+), 1 deletion(-) create mode 100644 src/hmz/web/proxy.py create mode 100644 src/hmz/web/remote.py create mode 100644 tests/integration/doubles_ssh.py create mode 100644 tests/integration/test_web_remote.py create mode 100644 tests/unit/web/test_remote.py diff --git a/src/hmz/web/__init__.py b/src/hmz/web/__init__.py index 5c6d7197..efdcb16e 100644 --- a/src/hmz/web/__init__.py +++ b/src/hmz/web/__init__.py @@ -60,6 +60,7 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]: from hmz.coganchor import releases from .places import Places + from .remote import Remotes from .runs import Runs from .site import Site from .verses import fetches @@ -73,6 +74,9 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]: # 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) # Set first as the block ends, which is what ends every stream being sent. held_open.callback(site.going.set) yield site diff --git a/src/hmz/web/proxy.py b/src/hmz/web/proxy.py new file mode 100644 index 00000000..1bb712f6 --- /dev/null +++ b/src/hmz/web/proxy.py @@ -0,0 +1,133 @@ +"""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", + } +) + +#: 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(): + if name.lower() not in _KEPT: + 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..b179d76f --- /dev/null +++ b/src/hmz/web/remote.py @@ -0,0 +1,677 @@ +"""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=max(0.0, ending - time.monotonic())) + except queue.Empty: + raise _Failed("hmz web there did not say where it listens.") from None + 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..40ea50d6 100644 --- a/src/hmz/web/site.py +++ b/src/hmz/web/site.py @@ -44,6 +44,8 @@ held, paths, places, + proxy, + remote, runs, runtimes, settings, @@ -55,6 +57,7 @@ from collections.abc import Generator from .places import Places + from .remote import Remotes from .runs import Runs __all__ = ["Site"] @@ -71,8 +74,12 @@ *paths.ROUTES, *places.ROUTES, *verses.ROUTES, + *remote.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 @@ -152,6 +159,8 @@ def __init__(self, port: int, *, places: Places, runs: Runs) -> None: self.places = places self.hmz: Any = places.hmz self.runs = runs + #: The daemons on other machines, as each is reached, once `listening` has them. + self.remotes: Remotes | 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) @@ -284,6 +293,8 @@ def _routes(self, method: str, path: str, query: dict[str, str]) -> None: ) 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 +363,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: diff --git a/tests/integration/doubles_ssh.py b/tests/integration/doubles_ssh.py new file mode 100644 index 00000000..6f37500d --- /dev/null +++ b/tests/integration/doubles_ssh.py @@ -0,0 +1,216 @@ +"""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 os +import json +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 +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_remote.py b/tests/integration/test_web_remote.py new file mode 100644 index 00000000..0968d84a --- /dev/null +++ b/tests/integration/test_web_remote.py @@ -0,0 +1,270 @@ +"""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 http.client +import os +import socket +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) + written( + { + "name": "box", + "host": "box.example", + "user": "me", + "options": [["Compression", "yes"]], + } + ) + return found + + +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}" + 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, answer.body + 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/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", + } From 5e2a47673f55e4784f2dbc2ffe2e6a0ff4587d9c Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:55:04 +0800 Subject: [PATCH 13/16] fix(web): make the daemon and notification units work together - The daemon bar reads `{daemons: [...]}`, switches through `toDaemon`, and keeps `@` on its links. - Settings drops the optional-page guard now that both pages exist, and its links keep `@`. - The Daemons page saves `{name, fields, replace}`, and a rename removes the old entry. - A view on a daemon that cannot be reached no longer throws: `places.js` catches the refusal. - The notification settings are read only when a system notification is about to show. - `style.css` is rebuilt from each unit's own block, with every brace closed, and the daemon bar no longer cuts "Local" short. - The browser tests move under `tests/system/web`, as #369 moved them. Their fakes now follow the merged API, and `opened()` takes `routed=`. - A new integration test covers a chat on an SSH daemon notifying Local. - Docs: the proxy, the state and connect endpoints, the `daemons` key, and one Notifications section on the desktop page. Co-Authored-By: Claude Opus 5.5 --- docs/reference/settings.md | 9 +++ docs/reference/web.md | 3 + docs/user/desktop.md | 17 +---- src/hmz/web/notices.py | 10 +-- src/hmz/web/static/notifications.js | 15 ++-- src/hmz/web/static/places.js | 14 ++-- src/hmz/web/static/style.css | 17 ++++- src/hmz/web/static/views/settings/daemons.js | 16 ++--- tests/integration/doubles_ssh.py | 2 +- .../integration/test_web_daemons_together.py | 72 +++++++++++++++++++ tests/integration/test_web_notices.py | 6 +- .../web}/test_web_daemon_bar.py | 5 +- .../web}/test_web_notification_centre.py | 18 ++++- .../web}/test_web_scoping.py | 6 +- .../web}/test_web_settings_daemons.py | 18 +++-- .../web}/test_web_settings_pages.py | 30 +++++--- 16 files changed, 186 insertions(+), 72 deletions(-) create mode 100644 tests/integration/test_web_daemons_together.py rename tests/{integration => system/web}/test_web_daemon_bar.py (95%) rename tests/{integration => system/web}/test_web_notification_centre.py (93%) rename tests/{integration => system/web}/test_web_scoping.py (95%) rename tests/{integration => system/web}/test_web_settings_daemons.py (92%) rename tests/{integration => system/web}/test_web_settings_pages.py (91%) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index af163419..0626d5b4 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -46,6 +46,14 @@ 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 @@ -79,6 +87,7 @@ 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 | diff --git a/docs/reference/web.md b/docs/reference/web.md index 02dc6bf5..a9ddf1f1 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -289,6 +289,9 @@ Every `error` but the last is the runtime's own sentence, e.g. | `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. diff --git a/docs/user/desktop.md b/docs/user/desktop.md index 9ccb7cb5..a8e8ce1b 100644 --- a/docs/user/desktop.md +++ b/docs/user/desktop.md @@ -60,22 +60,11 @@ The app is a window, not a second humanize. It runs `hmz` as - **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 notifies as macOS does.** See [Notifications](#notifications). - **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). -## Notifications {#notifications} - -The page's [notifications](/user/web#notifications) come from the app itself, so they -look and stay as any Mac app's do. The first one asks whether humanize may send them: choose -**Allow**. To change that later, open **System Settings → Notifications → humanize**. - -Clicking one brings the window forward, on what it is about: the epic that ended, or the daemon -whose flow asked. Which kinds you are told of is set on the page, in **Settings → -Notifications**. - ## Opening a `.hmzepic` {#opening-a-hmzepic} The app is what a `.hmzepic` -- an [exported epic](/user/export) -- opens with. Double-click one @@ -95,9 +84,9 @@ and toasts as in a browser, and, while the window is hidden or not in front, as 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. -macOS's own **System Settings → Notifications → humanize** decides how they are shown, or -silences them. +**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} diff --git a/src/hmz/web/notices.py b/src/hmz/web/notices.py index af4988c8..b8b936d3 100644 --- a/src/hmz/web/notices.py +++ b/src/hmz/web/notices.py @@ -67,9 +67,7 @@ def __init__( self._statuses = statuses self._every = every self._changed = threading.Condition() - self._kept: collections.deque[dict[str, Any]] = collections.deque( - maxlen=_KEPT - ) + 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. @@ -219,11 +217,7 @@ def _noticed( """ made: list[dict[str, Any]] = [] ended = now.get("ended") - if ( - ended - and ended != was.get("ended") - and float(ended.get("at") or began) >= began - ): + if ended and ended != was.get("ended") and float(ended.get("at") or began) >= began: why = str(ended.get("why") or "").strip() made.append( { diff --git a/src/hmz/web/static/notifications.js b/src/hmz/web/static/notifications.js index 1a7ecd19..8e425f03 100644 --- a/src/hmz/web/static/notifications.js +++ b/src/hmz/web/static/notifications.js @@ -48,8 +48,6 @@ export function bell() { let wait = 10000 /** What was heard since the list was last asked for, which an older answer must not drop. */ let fresh = new Map() -/** Whether the system is let show them, as the settings said when last read. */ -let settings = Promise.resolve(null) async function start() { try { @@ -63,14 +61,12 @@ async function start() { return } button.hidden = false - readSettings() 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; the settings as it is left. + // looked at again, for what another tab read or cleared. stream.addEventListener('open', () => reread().catch(() => {})) - document.addEventListener('visibilitychange', () => (document.hidden ? readSettings() : reread().catch(() => {}))) - addEventListener('blur', readSettings) + document.addEventListener('visibilitychange', () => document.hidden || reread().catch(() => {})) } async function reread() { @@ -81,10 +77,6 @@ async function reread() { draw() } -function readSettings() { - settings = get('/api/notifications/settings').catch(() => null) -} - function heard(item) { fresh.set(item.id, item) if (items.some((one) => one.id === item.id)) return @@ -179,7 +171,8 @@ function card(item) { /** 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 - const said = await settings + // 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) diff --git a/src/hmz/web/static/places.js b/src/hmz/web/static/places.js index 87594312..f25e7521 100644 --- a/src/hmz/web/static/places.js +++ b/src/hmz/web/static/places.js @@ -99,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/style.css b/src/hmz/web/static/style.css index aea9889a..2f2794c6 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3855,8 +3855,13 @@ input[aria-invalid='true'] { } .side-daemon b { - flex: none; - max-width: 70%; + 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 { @@ -3884,6 +3889,8 @@ input[aria-invalid='true'] { .side-account[data-state='failed'] .side-avatar { background: var(--fail); +} + /* settings daemons */ .settings-group { margin: 12px 10px 4px; @@ -3948,6 +3955,9 @@ input[aria-invalid='true'] { .settings-daemon { padding: 8px 12px; + } +} + /* settings: daemons, notifications */ .daemon-state .glyph { margin-right: 6px; @@ -4007,6 +4017,9 @@ input[aria-invalid='true'] { .daemons-table td:last-child .row { flex-wrap: wrap; justify-content: flex-start; + } +} + /* notifications */ .notify-bell { diff --git a/src/hmz/web/static/views/settings/daemons.js b/src/hmz/web/static/views/settings/daemons.js index 27682ede..5341280a 100644 --- a/src/hmz/web/static/views/settings/daemons.js +++ b/src/hmz/web/static/views/settings/daemons.js @@ -139,20 +139,20 @@ export function draw(holder) { acting( editing ? 'Save' : 'Add', async () => { - const entry = { - ...(editing || {}), - name: name.value.trim(), + const fields = { host: host.value.trim(), user: user.value.trim(), - port: port.value === '' ? null : Number(port.value), identity_file: key.value(), proxy_jump: jump.value.trim(), options: options.read(), } - delete entry.state - delete entry.detail - delete entry.version - await post('/api/daemons', entry) + 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() diff --git a/tests/integration/doubles_ssh.py b/tests/integration/doubles_ssh.py index 6f37500d..3b9b3839 100644 --- a/tests/integration/doubles_ssh.py +++ b/tests/integration/doubles_ssh.py @@ -13,8 +13,8 @@ from __future__ import annotations -import os import json +import os import sys import tempfile from dataclasses import dataclass diff --git a/tests/integration/test_web_daemons_together.py b/tests/integration/test_web_daemons_together.py new file mode 100644 index 00000000..fd8fc9f0 --- /dev/null +++ b/tests/integration/test_web_daemons_together.py @@ -0,0 +1,72 @@ +"""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 index ad1cf26e..53e65459 100644 --- a/tests/integration/test_web_notices.py +++ b/tests/integration/test_web_notices.py @@ -102,7 +102,11 @@ def test_a_chat_waiting_and_an_epic_ending_are_notices(project: Path) -> None: assert browser.get("/api/notices").json() == {"notices": [], "last": 0} with _follows(browser) as heard: - chat = {"flow": "chat", "task": "hello there", "agents": {"assistant": AGENT}} + 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") diff --git a/tests/integration/test_web_daemon_bar.py b/tests/system/web/test_web_daemon_bar.py similarity index 95% rename from tests/integration/test_web_daemon_bar.py rename to tests/system/web/test_web_daemon_bar.py index 3c647bb0..70c4952e 100644 --- a/tests/integration/test_web_daemon_bar.py +++ b/tests/system/web/test_web_daemon_bar.py @@ -15,7 +15,8 @@ from playwright.sync_api import expect from tests.integration.doubles_core import install -from tests.integration.doubles_web import ENGINES, opened, served +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, opened if TYPE_CHECKING: from pathlib import Path @@ -95,7 +96,7 @@ def test_the_bar_names_the_daemon_and_its_picker_switches( bar.click() picker.get_by_role("menuitem", name="Manage").click() - page.wait_for_url(re.compile(r".*#/settings/daemons$")) + page.wait_for_url(re.compile(r".*#/@lab/settings/daemons$")) def test_without_daemons_the_bar_is_local(project: Path, engine: str) -> None: diff --git a/tests/integration/test_web_notification_centre.py b/tests/system/web/test_web_notification_centre.py similarity index 93% rename from tests/integration/test_web_notification_centre.py rename to tests/system/web/test_web_notification_centre.py index f2f218ae..00aa581c 100644 --- a/tests/integration/test_web_notification_centre.py +++ b/tests/system/web/test_web_notification_centre.py @@ -10,13 +10,15 @@ 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 ENGINES, opened, served +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 @@ -177,11 +179,23 @@ def test_a_notification_heard_is_a_card_that_opens_it(engine: str) -> None: def test_there_is_no_bell_where_the_server_has_no_notifications(engine: str) -> None: - with served() as site, opened(site, engine=engine) as page: + 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 diff --git a/tests/integration/test_web_scoping.py b/tests/system/web/test_web_scoping.py similarity index 95% rename from tests/integration/test_web_scoping.py rename to tests/system/web/test_web_scoping.py index d681a345..b77ad352 100644 --- a/tests/integration/test_web_scoping.py +++ b/tests/system/web/test_web_scoping.py @@ -16,7 +16,8 @@ from playwright.sync_api import expect from tests.integration.doubles_core import install -from tests.integration.doubles_web import ENGINES, go, opened, served +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, go, opened if TYPE_CHECKING: from pathlib import Path @@ -38,7 +39,8 @@ def _asked(page: Page) -> list[str]: def heard(request: Request) -> None: path = urllib.parse.urlsplit(request.url).path - if "/api/" in 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) diff --git a/tests/integration/test_web_settings_daemons.py b/tests/system/web/test_web_settings_daemons.py similarity index 92% rename from tests/integration/test_web_settings_daemons.py rename to tests/system/web/test_web_settings_daemons.py index c8385990..d70aeb28 100644 --- a/tests/integration/test_web_settings_daemons.py +++ b/tests/system/web/test_web_settings_daemons.py @@ -1,5 +1,7 @@ -"""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. +"""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 @@ -16,7 +18,8 @@ from playwright.sync_api import expect from tests.integration.doubles_core import install -from tests.integration.doubles_web import ENGINES, go, opened, served +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, go, opened if TYPE_CHECKING: from pathlib import Path @@ -53,6 +56,10 @@ def remote(route: Route) -> None: 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": @@ -104,10 +111,7 @@ def test_the_daemon_picked_is_the_one_read_and_written( 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 - ) + 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() diff --git a/tests/integration/test_web_settings_pages.py b/tests/system/web/test_web_settings_pages.py similarity index 91% rename from tests/integration/test_web_settings_pages.py rename to tests/system/web/test_web_settings_pages.py index 12ad7baa..33e889e8 100644 --- a/tests/integration/test_web_settings_pages.py +++ b/tests/system/web/test_web_settings_pages.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import re import urllib.parse from typing import TYPE_CHECKING, Any @@ -15,7 +16,8 @@ from playwright.sync_api import expect from tests.integration.doubles_core import install -from tests.integration.doubles_web import ENGINES, go, opened, served +from tests.integration.doubles_web import served +from tests.system.web.doubles_browser import ENGINES, go, opened if TYPE_CHECKING: from pathlib import Path @@ -71,7 +73,7 @@ def answer(self, route: Route) -> None: return elif route.request.method == "POST": self.daemons = [one for one in self.daemons if one["name"] != body["name"]] - self.daemons.append(body) + self.daemons.append({"name": body["name"], **body["fields"]}) said = self.listed() else: said = self.listed() @@ -111,12 +113,15 @@ def test_a_daemon_is_added_a_part_at_a_time(project: Path, engine: str) -> None: "/api/daemons", { "name": "lab", - "host": "lab.example", - "user": "ann", - "port": 2222, - "identity_file": "~/.ssh/lab", - "proxy_jump": "me@jump", - "options": [["ServerAliveInterval", "30"]], + "fields": { + "host": "lab.example", + "user": "ann", + "port": 2222, + "identity_file": "~/.ssh/lab", + "proxy_jump": "me@jump", + "options": [["ServerAliveInterval", "30"]], + }, + "replace": False, }, ) @@ -161,7 +166,14 @@ def test_a_refused_connection_is_said_where_it_happened( def test_without_the_endpoints_the_pages_say_unavailable( project: Path, engine: str ) -> None: - with served() as site, opened(site, engine=engine) as page: + 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}]") From bf9aea3bff9a928a3a6b419b98c1473192c2c8aa Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:26:55 +0800 Subject: [PATCH 14/16] fix(web): carry no header that splits a line, and say why a remote hmz web never listened Also formats the daemons test, and has the remote tests list the stand-in's processes when a daemon never comes up, so CI says where it waits. Co-Authored-By: Claude Opus 5.5 --- src/hmz/web/proxy.py | 6 +++++- src/hmz/web/remote.py | 4 +++- .../integration/test_web_daemons_together.py | 5 ++++- tests/integration/test_web_remote.py | 21 +++++++++++++++++-- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/hmz/web/proxy.py b/src/hmz/web/proxy.py index 1bb712f6..c09c0c68 100644 --- a/src/hmz/web/proxy.py +++ b/src/hmz/web/proxy.py @@ -43,6 +43,9 @@ } ) +#: 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 @@ -78,7 +81,8 @@ def forwards( raise Refusal("The daemon did not answer.", 502) from why handler.send_response(answer.status, answer.reason) for name, value in answer.getheaders(): - if name.lower() not in _KEPT: + # 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. diff --git a/src/hmz/web/remote.py b/src/hmz/web/remote.py index b179d76f..609e12b8 100644 --- a/src/hmz/web/remote.py +++ b/src/hmz/web/remote.py @@ -440,7 +440,9 @@ def reads() -> None: try: line = heard.get(timeout=max(0.0, ending - time.monotonic())) except queue.Empty: - raise _Failed("hmz web there did not say where it listens.") from None + raise _Failed( + _last(errors, "hmz web there did not say where it listens.") + ) from None if line is None: web.wait() raise _Failed(_last(errors, "hmz web there stopped.")) diff --git a/tests/integration/test_web_daemons_together.py b/tests/integration/test_web_daemons_together.py index fd8fc9f0..e8ffdcd7 100644 --- a/tests/integration/test_web_daemons_together.py +++ b/tests/integration/test_web_daemons_together.py @@ -68,5 +68,8 @@ def test_a_chat_on_another_machine_is_told_here(host: Host) -> None: 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 ( + 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_remote.py b/tests/integration/test_web_remote.py index 0968d84a..fb6a7787 100644 --- a/tests/integration/test_web_remote.py +++ b/tests/integration/test_web_remote.py @@ -8,7 +8,9 @@ import http.client import os +import re import socket +import subprocess import time from typing import TYPE_CHECKING, Any @@ -40,11 +42,26 @@ def host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Host: return found +def _processes() -> str: + """What runs of the stand-in's, to say where a daemon that never came up stopped.""" + listed = subprocess.run( + ["ps", "-axo", "pid,ppid,etime,command"], + capture_output=True, + text=True, + check=False, + ).stdout + return "\n".join( + line for line in listed.splitlines() if re.search(r"ssh|hmz|\bsh\b", line) + ) + + 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}" + assert time.monotonic() < ending, ( + f"{name} never came to {states}: {said}\n{_processes()}" + ) time.sleep(0.05) return said @@ -53,7 +70,7 @@ 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, answer.body + assert time.monotonic() < ending, f"{answer.body!r}\n{_processes()}" time.sleep(0.1) return answer From ea4e87298cd25c3edda138abcb5f2e6b7386b1dc Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:40:16 +0800 Subject: [PATCH 15/16] fix(web): settle where a stream starts before it is answered, and say what a remote hmz web says as it comes up A notification sent between a stream's headers and its first read was lost, which hung the stream test on Linux. The stand-in remote now dumps its stack on SIGABRT, so CI says where a remote hmz web hangs on macOS. Co-Authored-By: Claude Opus 5.5 --- src/hmz/web/notifications.py | 9 +++++++-- src/hmz/web/remote.py | 13 +++++++++---- src/hmz/web/site.py | 5 ++++- tests/integration/doubles_ssh.py | 2 ++ tests/integration/test_web_remote.py | 23 +++++++++++++++++++---- 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/hmz/web/notifications.py b/src/hmz/web/notifications.py index ceb19398..b30391de 100644 --- a/src/hmz/web/notifications.py +++ b/src/hmz/web/notifications.py @@ -197,9 +197,14 @@ def _events( 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. + # 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 - after = heard if heard <= told else 0 + 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] diff --git a/src/hmz/web/remote.py b/src/hmz/web/remote.py index 609e12b8..adb62409 100644 --- a/src/hmz/web/remote.py +++ b/src/hmz/web/remote.py @@ -438,11 +438,16 @@ def reads() -> None: ending = time.monotonic() + _STARTING while True: try: - line = heard.get(timeout=max(0.0, ending - time.monotonic())) + line = heard.get(timeout=min(1.0, max(0.0, ending - time.monotonic()))) except queue.Empty: - raise _Failed( - _last(errors, "hmz web there did not say where it listens.") - ) from None + 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.")) diff --git a/src/hmz/web/site.py b/src/hmz/web/site.py index 133f1d83..2549a9c7 100644 --- a/src/hmz/web/site.py +++ b/src/hmz/web/site.py @@ -583,6 +583,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") @@ -593,7 +596,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/tests/integration/doubles_ssh.py b/tests/integration/doubles_ssh.py index 3b9b3839..a620212e 100644 --- a/tests/integration/doubles_ssh.py +++ b/tests/integration/doubles_ssh.py @@ -102,6 +102,8 @@ def serves(listening, to): _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 "$@" """ diff --git a/tests/integration/test_web_remote.py b/tests/integration/test_web_remote.py index fb6a7787..7afa4b76 100644 --- a/tests/integration/test_web_remote.py +++ b/tests/integration/test_web_remote.py @@ -6,9 +6,11 @@ from __future__ import annotations +import contextlib import http.client import os import re +import signal import socket import subprocess import time @@ -31,6 +33,7 @@ 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", @@ -42,17 +45,29 @@ def host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Host: 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, to say where a daemon that never came up stopped.""" + """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 - return "\n".join( - line for line in listed.splitlines() if re.search(r"ssh|hmz|\bsh\b", line) - ) + 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]: From 527b4e9d2db1cd7a861533f0e3e529c6501f7f56 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:50:56 +0800 Subject: [PATCH 16/16] fix(web): bind without a reverse DNS lookup of the loopback HTTPServer.server_bind asks socket.getfqdn for a name the interface never uses, and on macOS runners that lookup hung long enough that a remote hmz web never said its address. Co-Authored-By: Claude Opus 5.5 --- src/hmz/web/site.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/hmz/web/site.py b/src/hmz/web/site.py index 2549a9c7..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 @@ -135,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 @@ -227,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: