Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: a swarm tab after agents holding the hives card and the beta notification router; /notifications redirects to it

This commit is contained in:
iris 2026-11-04 20:56:24 +01:00 • committed by mara
commit 0302582d1c
12 changed files with 94 additions and 49 deletions

View file

@ -83,7 +83,7 @@ todo, and `head-classifier`: source `pull_request` and `pull_request_sync` →
match on `action` (`opened`, `reopened`, `synchronized`) and `draft` = `false`, or on match on `action` (`opened`, `reopened`, `synchronized`) and `draft` = `false`, or on
`undrafted` → `request_reviewer` for `review-classifier` ([below](#requesting-a-review)). The code defines them `undrafted` → `request_reviewer` for `review-classifier` ([below](#requesting-a-review)). The code defines them
(`swarm_wire::notify_flows::builtins`), so a build that changes one reaches (`swarm_wire::notify_flows::builtins`), so a build that changes one reaches
every swarm. Each switches off on `/notifications`. A custom flow has the same type and every swarm. Each switches off on the notification router card on `/swarm`. A custom flow has the same type and
the same evaluator, so it can do anything a built-in does. the same evaluator, so it can do anything a built-in does.
**The stored document.** swarm-controller keeps the rest in one document, the **The stored document.** swarm-controller keeps the rest in one document, the
@ -96,7 +96,7 @@ check above and reads only what exists: a source event from `issues`,
three `pull_request_review_*` events, and condition and recipient fields from the event's field list. The same goes for the `{field}` three `pull_request_review_*` events, and condition and recipient fields from the event's field list. The same goes for the `{field}`
placeholders in a todo's key and text. A `PUT` must carry the `revision` it read, `0` for no placeholders in a todo's key and text. A `PUT` must carry the `revision` it read, `0` for no
document, and gets 409 when the stored revision is another. Swarm UI's document, and gets 409 when the stored revision is another. Swarm UI's
[`/notifications`](ui.md#notification-flows) page shows the document and [notification router card on `/swarm`](ui.md#notification-flows) shows the document and
switches built-ins. switches built-ins.
**The schema.** `GET /api/notify/flows` also returns a `schema` object, built **The schema.** `GET /api/notify/flows` also returns a `schema` object, built
@ -263,7 +263,7 @@ serves them next to `GET /api/notify/flows`:
The dry-run request carries a whole document. The `GET` sends the saved one; The dry-run request carries a whole document. The `GET` sends the saved one;
the `POST` takes a `PUT` body (it ignores a `revision`), answers 400 the `POST` takes a `PUT` body (it ignores a `revision`), answers 400
with the `PUT` error for a document that fails the check, and forwards a valid one. with the `PUT` error for a document that fails the check, and forwards a valid one.
[`/notifications`](ui.md#notification-flows) shows both: the recent deliveries, [notification router card on `/swarm`](ui.md#notification-flows) shows both: the recent deliveries,
and the counts beside each node. and the counts beside each node.
**Deploying in any order.** A router that predates these subjects doesn't **Deploying in any order.** A router that predates these subjects doesn't

View file

@ -26,13 +26,13 @@ smaller; short confirm dialogs stay short.
| route | what | | route | what |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop with the slug (`hyperhive / we are the wired`, its shaded gradients pulsing in a wave unless reduced motion is on) at the foot, plus a paperclip offering to set up an agent while no `ruth` exists, or her hive declares her destroyed (it opens the create form with `ruth` filled in); the swarm name in the header links here | | `/` | the start page: the swarm's name over a digital-rain backdrop with the slug (`hyperhive / we are the wired`, its shaded gradients pulsing in a wave unless reduced motion is on) at the foot, plus a paperclip offering to set up an agent while no `ruth` exists, or her hive declares her destroyed (it opens the create form with `ruth` filled in); the swarm name in the header links here |
| `/agents` | every agent as a card that opens in place (status, terminal, subagents, and settings with permissions, internet, allowed domains and accounts), under a folded **hives** card with the hive directory and each hive's last reported status; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts | | `/agents` | every agent as a card that opens in place (status, terminal, subagents, and settings with permissions, internet, allowed domains and accounts); create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
| `/swarm` | what belongs to the whole swarm: a folded **hives** card with the hive directory and each hive's last reported status, and a folded, **beta** **notification router** card (with a warning banner) holding the router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows. `/notifications` redirects here |
| `/agents/<name>/terminal` | one agent's live terminal | | `/agents/<name>/terminal` | one agent's live terminal |
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal | | `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
| `/reports` | two folded cards, each read only once opened: the **alerts** the swarm's Grafana holds now, the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token, and a cross-repo **issue report** | | `/reports` | two folded cards, each read only once opened: the **alerts** the swarm's Grafana holds now, the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token, and a cross-repo **issue report** |
| `/jobs` | the controller's job graph, where agent creation and credential mints show progress, and under it the swarm builder's recent builds (agent config PRs, and agents built from `internal/meta` before a deploy), with what nix printed when one failed, and the review classifier's recent runs, a try-out on a real PR, and its settings | | `/jobs` | the controller's job graph, where agent creation and credential mints show progress, and under it the swarm builder's recent builds (agent config PRs, and agents built from `internal/meta` before a deploy), with what nix printed when one failed, and the review classifier's recent runs, a try-out on a real PR, and its settings |
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to more than one agent at once, and a folded **schedules** card holds the scheduled prompts: add, edit, pause, resume and remove | | `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to more than one agent at once, and a folded **schedules** card holds the scheduled prompts: add, edit, pause, resume and remove |
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows |
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
Pages that refresh on a timer share one polling rule: the page sends no new Pages that refresh on a timer share one polling rule: the page sends no new
@ -58,7 +58,7 @@ works too.
### The agents page ### The agents page
`/agents` is a column of separate cards: the hives card, a filter card, then one `/agents` is a column of separate cards: a filter card, then one
card per agent, none of them inside a bigger card. A card's card per agent, none of them inside a bigger card. A card's
header shows its icon, name, freshness, the status message, a **terminal** header shows its icon, name, freshness, the status message, a **terminal**
button that opens the agent's terminal tab, the **wanted** menu (up, offline, button that opens the agent's terminal tab, the **wanted** menu (up, offline,
@ -295,7 +295,7 @@ routes.
### Notification flows ### Notification flows
`/notifications` lists the [notification router's flows](notify-router.md#flows) The **notification router** card on `/swarm` (beta, folded; `/notifications` redirects to it) lists the [notification router's flows](notify-router.md#flows)
in two groups. Each **built-in** flow has an on/off switch; switching one off in two groups. Each **built-in** flow has an on/off switch; switching one off
stops the router running it from its next event, and switching it on again stops the router running it from its next event, and switching it on again
restores it. Each **custom** flow shows its name and whether it's on. Every restores it. Each **custom** flow shows its name and whether it's on. Every

View file

@ -9,7 +9,7 @@ import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
import { MessagesPage } from "./pages/messages/MessagesPage.js"; import { MessagesPage } from "./pages/messages/MessagesPage.js";
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js"; import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js"; import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js"; import { SwarmPage } from "./pages/swarm/SwarmPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js"; import { JobsPage } from "./pages/JobsPage.js";
import { ReportsPage } from "./pages/reports/ReportsPage.js"; import { ReportsPage } from "./pages/reports/ReportsPage.js";
@ -50,7 +50,7 @@ export function App() {
/> />
<Route path="/jobs" component={JobsPage} /> <Route path="/jobs" component={JobsPage} />
<Route path="/messages" component={MessagesPage} /> <Route path="/messages" component={MessagesPage} />
<Route path="/notifications" component={NotificationsPage} /> <Route path="/swarm" component={SwarmPage} />
<Route path={NEW_FLOW_PATH}> <Route path={NEW_FLOW_PATH}>
<FlowEditorPage params={{ flowId: "" }} isNew /> <FlowEditorPage params={{ flowId: "" }} isNew />
</Route> </Route>

View file

@ -1,10 +0,0 @@
/* The folded hives card over the agents. */
.ui-agents-hives {
margin-block-end: 0.5em;
}
/* The roster's refresh picker, at the top right of the open card. */
.ui-hives-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}

View file

@ -4,7 +4,7 @@
// status panel has anything to render against without a roster to embed // status panel has anything to render against without a roster to embed
// in — so all three ended up as one page. // in — so all three ended up as one page.
// //
// One fetch, on a refresh-interval cadence like HivesCard: `GET // One fetch, on a refresh-interval cadence like the hives card: `GET
// /api/agents/status` — one row per roster agent (identity store is the // /api/agents/status` — one row per roster agent (identity store is the
// roster, so a never-reported agent still gets a row), each already // roster, so a never-reported agent still gets a row), each already
// carrying its freshness/snapshot/config PR/wanted state. Used to be // carrying its freshness/snapshot/config PR/wanted state. Used to be
@ -36,7 +36,6 @@ import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js"; import { Dialog } from "../../ui/dialog/Dialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js"; import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { HivesCard } from "./HivesCard.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
type RefreshIntervalMs, type RefreshIntervalMs,
@ -44,9 +43,8 @@ import {
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { type FilterColumn } from "../../ui/table/Table.js"; import { type FilterColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.js"; import { CreateAgentForm } from "../CreateAgentForm.js";
import "./AgentsPage.css";
// Same 30s default + same reasoning as HivesCard: no inputs on this page // Same 30s default + same reasoning as the hives card: no inputs on this page
// for a refresh to clobber, so the out-of-the-box behaviour should just // for a refresh to clobber, so the out-of-the-box behaviour should just
// solve staleness rather than require an opt-in every visit. // solve staleness rather than require an opt-in every visit.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
@ -139,9 +137,6 @@ export function AgentsPage() {
return ( return (
<> <>
<div class="ui-agents-hives">
<HivesCard />
</div>
{/* Until the roster loads (or when it fails) there are no filters, but the {/* Until the roster loads (or when it fails) there are no filters, but the
buttons stay, so an agent can still be created. */} buttons stay, so an agent can still be created. */}
{rows === null ? ( {rows === null ? (

View file

@ -1,6 +1,7 @@
// <NotificationsPage> — the notification router's flows. Backed by // <NotificationRouter> — the notification router's flows, the body of its card on
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only // the swarm page. Backed by swarm-controller's `GET/PUT /api/notify/flows`; the
// writer of the stored document and the router reads it every few seconds. // controller is the only writer of the stored document and the router reads it
// every few seconds.
// //
// A flow draws as its graph, read-only. A built-in flow has a switch and can // A flow draws as its graph, read-only. A built-in flow has a switch and can
// be duplicated; a custom flow is edited on its own page (<FlowEditorPage>). // be duplicated; a custom flow is edited on its own page (<FlowEditorPage>).
@ -13,7 +14,6 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { Card } from "../../ui/card/Card.js"; import { Card } from "../../ui/card/Card.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { Panel } from "../../ui/panel/Panel.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
type RefreshIntervalMs, type RefreshIntervalMs,
@ -35,7 +35,7 @@ import "./NotificationsPage.css";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
export function NotificationsPage() { export function NotificationRouter() {
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { const {
@ -96,22 +96,17 @@ export function NotificationsPage() {
); );
return ( return (
<Panel <>
title="notifications" <div class="notify-panel-actions">
icon="🔔" <RefreshIntervalPicker
actions={ id="notifications-refresh"
<div class="notify-panel-actions"> value={intervalMs}
<RefreshIntervalPicker onChange={setIntervalMs}
id="notifications-refresh" />
value={intervalMs} <Button variant="primary" onClick={() => draftFrom()}>
onChange={setIntervalMs} create flow
/> </Button>
<Button variant="primary" onClick={() => draftFrom()}> </div>
create flow
</Button>
</div>
}
>
{error ? ( {error ? (
<ApiErrorPanel context="failed to load the flows" problem={error} /> <ApiErrorPanel context="failed to load the flows" problem={error} />
) : null} ) : null}
@ -192,6 +187,6 @@ export function NotificationsPage() {
)} )}
</div> </div>
) : null} ) : null}
</Panel> </>
); );
} }

View file

@ -0,0 +1,162 @@
// <HivesCard> — the hive roster, a card on the agents page that starts folded.
// Fetches
// swarm-controller's `GET /api/hives/status`, the swarm-wide status
// aggregate: one row per roster hive, freshness derived at read time
// from the status bucket. Supersedes the earlier `GET /api/hives` +
// static "configured" chip placeholder now that the real rollup
// exists — same component, richer data, no rebuild, exactly the plan
// that placeholder's comment laid out. Its own component/file rather
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
// routing, a page owns its own fetch + render.
//
// Polls on a `RefreshIntervalPicker` cadence rather than fetching once
// at mount — no inputs on this page, so a re-fetch clobbering an
// in-progress edit (the hook's stated caller obligation) isn't a live
// concern here.
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { Table, type TableColumn } from "../../ui/table/Table.js";
import { domainCell } from "../hive-domain.js";
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
interface HiveStatus {
name: string;
domain: string | null;
dashboard: string | null;
freshness: Freshness;
last_seen_unix: number | null;
// `age_seconds` (a once-computed-at-fetch age) is part of the wire
// shape but deliberately unused here — rendering it directly is the
// exact bug `RelativeTime` exists to fix, so this page recomputes the
// age client-side from `last_seen_unix` instead.
}
// One tone + label per freshness value. `unknown` (reporting but absent
// from the roster) reads `negative` — it's the one case this endpoint
// cannot vouch for at all, per `status.rs`'s doc comment.
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
fresh: { tone: "positive", label: "fresh" },
stale: { tone: "warning", label: "stale" },
never_reported: { tone: "neutral", label: "never reported" },
unknown: { tone: "negative", label: "unknown" },
};
const COLUMNS: TableColumn<HiveStatus>[] = [
{
key: "name",
header: "name",
render: (h) => h.name,
sortBy: (h) => h.name,
filterValue: (h) => h.name,
},
{
key: "domain",
header: "domain",
render: (h) => {
const { text, href } = domainCell(h);
if (!text) return "—";
return href ? (
<a href={href} target="_blank" rel="noreferrer">
{text}
</a>
) : (
text
);
},
sortBy: (h) => domainCell(h).text,
filterValue: (h) => domainCell(h).text,
},
{
key: "status",
header: "status",
render: (h) => {
const { tone, label } = FRESHNESS[h.freshness];
return (
<Badge
tone={tone}
value={
h.last_seen_unix !== null ? (
<>
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
</>
) : (
label
)
}
/>
);
},
sortBy: (h) => FRESHNESS[h.freshness].label,
// Multiselect (not the single-value `"select"` mode) so the operator
// can filter for e.g. "stale or offline" in one pass rather than one
// freshness state at a time.
filterValues: (h) => [FRESHNESS[h.freshness].label],
filterMode: "multiselect",
},
];
// 30s default: this card has no inputs to interrupt, and the whole
// point of a refresh-interval control is "no manual reload needed" — an
// operator who wants it off still can, but the out-of-the-box behaviour
// should actually solve the staleness problem rather than require an
// opt-in every visit.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
// The roster is read on the first open and followed after, so an agents page
// where the card is never opened does not poll it.
function HiveRoster() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: hives, error } = usePolled(intervalMs, (signal) =>
fetchJson<HiveStatus[]>("/api/hives/status", signal),
);
return (
<>
<div class="ui-hives-refresh">
<RefreshIntervalPicker
id="hives-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</div>
{error ? (
<ApiErrorPanel
context="failed to load the hive roster"
problem={error}
/>
) : null}
{!error && hives === null ? <p>loading…</p> : null}
{hives ? (
<Table
columns={COLUMNS}
rows={hives}
rowKey={(h) => h.name}
emptyMessage="no hives in the roster yet"
storageKey="swarm-ui:hives:table-filters"
/>
) : null}
</>
);
}
export function HivesCard() {
return (
<LazyDisclosure
title="hives"
icon="🐝"
hint="the swarm's hives and when each last reported"
>
<HiveRoster />
</LazyDisclosure>
);
}

View file

@ -0,0 +1,19 @@
/* The cards on the swarm page: hives, then the notification router. */
.ui-swarm-card {
margin-block-end: 0.5em;
}
/* The roster's refresh picker, at the top right of the open card. */
.ui-hives-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}
/* The warning at the top of the beta notification router card. */
.ui-swarm-beta {
margin: 0 0 0.75em;
padding: 0.5em 0.75em;
border: 1px solid var(--yellow);
border-left-width: 4px;
border-radius: 0.4em;
background: color-mix(in srgb, var(--yellow) 12%, transparent);
}

View file

@ -0,0 +1,40 @@
// <SwarmPage> — what belongs to the swarm as a whole rather than to one agent:
// the hive roster and the notification router. Each is a card that starts
// folded and reads nothing until it is opened.
//
// The notification router is marked beta: its card says so beside the title and
// carries a warning banner inside.
import { Badge } from "@hive/shared/badge.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { NotificationRouter } from "../notifications/NotificationsPage.js";
import { HivesCard } from "./HivesCard.js";
import "./SwarmPage.css";
export function SwarmPage() {
return (
<>
<div class="ui-swarm-card">
<HivesCard />
</div>
<div class="ui-swarm-card">
<LazyDisclosure
title="notification router"
icon="🔔"
hint={
<>
<Badge tone="warning" value="beta" /> forge events routed to
agents, as flows
</>
}
>
<p class="ui-swarm-beta" role="note">
<strong>beta:</strong> the notification router is still changing.
Flows, their wording and what they match may change, and a flow you
wrote may need rewriting after an update.
</p>
<NotificationRouter />
</LazyDisclosure>
</div>
</>
);
}

View file

@ -57,10 +57,10 @@ const SOLID_KEY = "swarm-ui:solid-backgrounds";
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/agents", label: "agents", accent: "var(--green)" }, { href: "/agents", label: "agents", accent: "var(--green)" },
{ href: "/swarm", label: "swarm", accent: "var(--yellow)" },
{ href: "/reports", label: "reports", accent: "var(--red)" }, { href: "/reports", label: "reports", accent: "var(--red)" },
{ href: "/jobs", label: "jobs", accent: "var(--pink)" }, { href: "/jobs", label: "jobs", accent: "var(--pink)" },
{ href: "/messages", label: "messages", accent: "var(--purple)" }, { href: "/messages", label: "messages", accent: "var(--purple)" },
{ href: "/notifications", label: "notifications", accent: "var(--yellow)" },
{ href: "/components", label: "components", accent: "var(--blue)" }, { href: "/components", label: "components", accent: "var(--blue)" },
]; ];

View file

@ -38,6 +38,7 @@ export const MOVED_ROUTES: Readonly<Record<string, string>> = {
"/builds": "/jobs", "/builds": "/jobs",
"/review": "/jobs", "/review": "/jobs",
"/schedules": "/messages", "/schedules": "/messages",
"/notifications": "/swarm",
}; };
/** Where an old address goes, keeping its query; `null` for any other path. */ /** Where an old address goes, keeping its query; `null` for any other path. */

View file

@ -69,3 +69,8 @@ test("no old address leads to another old address", () => {
assert.equal(to in MOVED_ROUTES, false, to); assert.equal(to in MOVED_ROUTES, false, to);
} }
}); });
test("the old notifications list goes to the swarm tab, the editor pages stay", () => {
assert.equal(movedHref("/notifications", ""), "/swarm");
assert.equal(movedHref("/notifications/some-flow", ""), null);
});