swarm: add a declared "paused" agent wanted state
mara (#4170): swarm-ui's wanted-state dropdown could only ever declare up/offline/destroy, with no way to swarm-declare the existing hive-local turn-loop pause (`hivectl agent pause|resume`). `AgentState::Paused` is not a fifth peer of Up/Offline/Destroyed on the power axis this enum otherwise answers — it's Up plus an orthogonal turn-loop pause. `hive-c0re`'s `workers::wanted` reconcile loop now decides the two axes independently (`decide` for power, the new `decide_pause` for the marker), so a stopped agent declared Paused converges with both a Start and a Pause in the same pass. Known, deliberate limitation: a Paused declaration on an agent this hive has never deployed only reaches Deploy this pass — writing the pause marker into a harness dir that may not exist yet was judged not worth the risk, so it converges on the next pass once the agent is present instead. swarm-ui's WantedMenu gains a fourth "paused" option (warning-tone badge). No separate "resume" entry — selecting "up" from a paused row already clears the marker via the same decide_pause path. Pause/resume marker writes go through one shared Coordinator::set_paused_by_name helper, used by both the interactive dashboard pause/resume handlers and this reconcile loop, instead of each duplicating the parse-name/write-marker/track-rescan shape. swarm-ui's "offline" and "paused" confirm dialogs share one confirmTarget state and one ConfirmDialog instead of two near-identical copies. Closes #4170
This commit is contained in:
parent
560f727797
commit
513554fe9a
6 changed files with 483 additions and 55 deletions
|
|
@ -28,6 +28,7 @@
|
|||
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
|
||||
// that component's own comment above `AgentsPage` for why.
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
|
|
@ -100,13 +101,47 @@ const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
|
|||
unknown: { tone: "negative", label: "not in swarm identity" },
|
||||
};
|
||||
|
||||
// The one thing that differs between the "offline" and "paused" confirm
|
||||
// dialogs `confirmTarget` drives — everything else (button row, open/close
|
||||
// wiring) is the shared `ConfirmDialog`.
|
||||
const CONFIRM_COPY: Record<
|
||||
"offline" | "paused",
|
||||
{
|
||||
label: string;
|
||||
confirmLabel: string;
|
||||
message: (agentName: string) => ComponentChildren;
|
||||
}
|
||||
> = {
|
||||
offline: {
|
||||
label: "stop agent",
|
||||
confirmLabel: "stop",
|
||||
message: (agentName) => (
|
||||
<p>
|
||||
Declare <strong>{agentName}</strong> offline? The hive brings its
|
||||
container down on its next reconcile sweep.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
paused: {
|
||||
label: "pause agent",
|
||||
confirmLabel: "pause",
|
||||
message: (agentName) => (
|
||||
<p>
|
||||
Declare <strong>{agentName}</strong> paused? Its container stays up —
|
||||
the web UI and MCP daemons keep running — but its turn loop parks on the
|
||||
hive's next reconcile sweep. Select "up" again to resume it.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
// Same 30s default + same reasoning as HivesPage: no inputs on this page
|
||||
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
||||
// solve staleness rather than require an opt-in every visit.
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
// The "wanted" column's control: one badge showing the current
|
||||
// declaration, opening a `Dropdown` with the three explicit states —
|
||||
// declaration, opening a `Dropdown` with the four explicit states —
|
||||
// replaces the old toggle-badge-plus-separate-destroy-badge pair.
|
||||
// Explicit options also fix a real bug the toggle had: for an
|
||||
// undeclared row, the toggle inferred a target as "the opposite of
|
||||
|
|
@ -117,26 +152,41 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
|||
// (mara: "when no state is declared, i want to set it to online").
|
||||
// Presentational only, same split as `StatusChips`'s `Picker`/
|
||||
// `StatusMenu`: the caller owns what each selection actually does.
|
||||
//
|
||||
// "paused" has no separate resume option here — selecting "up" from a
|
||||
// paused row is the resume, same PUT either way (`declareState`'s
|
||||
// backend counterpart, `hive-c0re`'s `decide_pause`, treats a declared
|
||||
// `Up` with the marker still set as "clear it"). A dedicated "resume"
|
||||
// entry would just be a second spelling of the option already above it.
|
||||
function WantedMenu({
|
||||
row,
|
||||
pending,
|
||||
onSelectUp,
|
||||
onSelectOffline,
|
||||
onSelectPaused,
|
||||
onDestroy,
|
||||
}: {
|
||||
row: AgentRow;
|
||||
pending: boolean;
|
||||
onSelectUp: (row: AgentRow) => void;
|
||||
onSelectOffline: (row: AgentRow) => void;
|
||||
onSelectPaused: (row: AgentRow) => void;
|
||||
onDestroy: (row: AgentRow) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const destroyed = row.wanted === "destroyed";
|
||||
const tone: BadgeTone =
|
||||
row.wanted === "up" ? "positive" : destroyed ? "negative" : "neutral";
|
||||
row.wanted === "up"
|
||||
? "positive"
|
||||
: row.wanted === "paused"
|
||||
? "warning"
|
||||
: destroyed
|
||||
? "negative"
|
||||
: "neutral";
|
||||
const options: DropdownOption[] = [
|
||||
{ value: "up", label: "up" },
|
||||
{ value: "paused", label: "paused" },
|
||||
{ value: "offline", label: "offline" },
|
||||
{ value: "destroy", label: "destroy", danger: true },
|
||||
];
|
||||
|
|
@ -170,6 +220,7 @@ function WantedMenu({
|
|||
setOpen(false);
|
||||
if (value === "up") onSelectUp(row);
|
||||
else if (value === "offline") onSelectOffline(row);
|
||||
else if (value === "paused") onSelectPaused(row);
|
||||
else onDestroy(row);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
|
|
@ -195,13 +246,22 @@ export function AgentsPage() {
|
|||
>(new Map());
|
||||
// The row pending destroy confirmation, `null` when the dialog is
|
||||
// closed — not a boolean, so the dialog can name the agent without a
|
||||
// second piece of state to keep in sync with it. `stopTarget` mirrors
|
||||
// it for `WantedMenu`'s "offline" option — two separate pieces of
|
||||
// state (not one "pending confirm" union) since both `ConfirmDialog`s
|
||||
// can't be open at once anyway and keeping them apart means each
|
||||
// one's JSX below reads standalone.
|
||||
// second piece of state to keep in sync with it. Kept apart from
|
||||
// `confirmTarget` below rather than folded into it as a third state:
|
||||
// destroy is categorically different (irreversible, longer warning,
|
||||
// danger-styled), not one more case of the same "confirm before parking
|
||||
// a running agent" shape "offline"/"paused" share.
|
||||
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
||||
const [stopTarget, setStopTarget] = useState<AgentRow | null>(null);
|
||||
// "offline" and "paused" are the *same* confirm shape — same button
|
||||
// treatment, same "disruptive to a running turn loop, unlike up" reason
|
||||
// for needing a confirm at all — so one union state + one `ConfirmDialog`
|
||||
// covers both instead of two near-identical copies (argus, PR review: "a
|
||||
// shared confirm-dialog-body-generator would drop real line count").
|
||||
// `CONFIRM_COPY` below supplies the one thing that differs.
|
||||
const [confirmTarget, setConfirmTarget] = useState<{
|
||||
row: AgentRow;
|
||||
state: "offline" | "paused";
|
||||
} | null>(null);
|
||||
// The row currently showing the "link a matrix account" dialog — same
|
||||
// null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of
|
||||
// state rather than folded into either since this dialog isn't a
|
||||
|
|
@ -284,16 +344,20 @@ export function AgentsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Fires from the `stopTarget` confirm `Dialog`. Used to be a native
|
||||
// `window.confirm` (stop is reversible, a later start un-does it, so a
|
||||
// lighter-weight prompt than destroy's felt proportionate) — mara,
|
||||
// reviewing the destroy confirm: use the shared component everywhere
|
||||
// we already confirm before acting, not just for destroy. Once
|
||||
// `ConfirmDialog` existed as a one-line-per-caller component, the
|
||||
// "native is lighter" argument no longer bought consistency anything.
|
||||
async function confirmStop(row: AgentRow) {
|
||||
setStopTarget(null);
|
||||
await declareState(row, "offline");
|
||||
// Fires from the `confirmTarget` confirm `Dialog` (covers both "offline"
|
||||
// and "paused" — see that state's own comment). Used to be a native
|
||||
// `window.confirm` for the "offline" case (stop is reversible, a later
|
||||
// start un-does it, so a lighter-weight prompt than destroy's felt
|
||||
// proportionate) — mara, reviewing the destroy confirm: use the shared
|
||||
// component everywhere we already confirm before acting, not just for
|
||||
// destroy. Once `ConfirmDialog` existed as a one-line-per-caller
|
||||
// component, the "native is lighter" argument no longer bought
|
||||
// consistency anything.
|
||||
async function confirmDeclare() {
|
||||
if (!confirmTarget) return;
|
||||
const { row, state } = confirmTarget;
|
||||
setConfirmTarget(null);
|
||||
await declareState(row, state);
|
||||
}
|
||||
|
||||
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
|
||||
|
|
@ -393,7 +457,12 @@ export function AgentsPage() {
|
|||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
onSelectUp={(row) => void declareState(row, "up")}
|
||||
onSelectOffline={setStopTarget}
|
||||
onSelectOffline={(row) =>
|
||||
setConfirmTarget({ row, state: "offline" })
|
||||
}
|
||||
onSelectPaused={(row) =>
|
||||
setConfirmTarget({ row, state: "paused" })
|
||||
}
|
||||
onDestroy={setDestroyTarget}
|
||||
/>
|
||||
{err ? (
|
||||
|
|
@ -512,18 +581,19 @@ export function AgentsPage() {
|
|||
) : null}
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={stopTarget !== null}
|
||||
label="stop agent"
|
||||
onCancel={() => setStopTarget(null)}
|
||||
onConfirm={() => stopTarget && void confirmStop(stopTarget)}
|
||||
confirmLabel="stop"
|
||||
open={confirmTarget !== null}
|
||||
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
|
||||
onCancel={() => setConfirmTarget(null)}
|
||||
onConfirm={() => void confirmDeclare()}
|
||||
confirmLabel={
|
||||
confirmTarget
|
||||
? CONFIRM_COPY[confirmTarget.state].confirmLabel
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{stopTarget ? (
|
||||
<p>
|
||||
Declare <strong>{stopTarget.name}</strong> offline? The hive brings
|
||||
its container down on its next reconcile sweep.
|
||||
</p>
|
||||
) : null}
|
||||
{confirmTarget
|
||||
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
|
||||
: null}
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={destroyTarget !== null}
|
||||
|
|
|
|||
Loading…
Reference in a new issue