Watch
0
0
Fork
You've already forked hyperhive
0

hive: remove the read-only schedules page and the old schedule store

Scheduled prompts live at swarm level (swarm-controller's
/api/schedules, `swarmctl schedule`, the swarm UI's /schedules page).
The hive dashboard's SCH3DUL3S tab only listed the rows left in
hive-c0re's `scheduled_prompts` table so operators could copy them
over; the operator confirmed everything is migrated.

Removed:
- the SCH3DUL3S tab, its pane, schedules.js, its CSS (including the
  `.rqe-source*` rules, whose only emitter was schedules.js) and the
  hub tile's "schedules" entry
- hive-c0re's GET /api/schedules and the `scheduled_prompts` store
  (with its v1 column migration and test)
- hive-sh4re's `schedule` wire types, which only that route used
- docs describing the tab, the route and the two tables

The rebuild-queue cancel route that shared the module moves to
dashboard/rebuild_queue.rs under an OpenAPI tag of its own.

The two sqlite tables are not dropped: nothing opens them any more,
and persistence.md says they can be dropped by hand.

Closes #4916
This commit is contained in:
atlas 2026-10-12 20:29:33 +02:00 • committed by mara
commit 78412b3d3b
22 changed files with 84 additions and 979 deletions

View file

@ -38,9 +38,8 @@ schemas, file layouts, and internal migration mechanics.
### `/var/lib/hyperhive/db/broker.sqlite` (host)
Five tables, all in one file — the message queue, a small key/value
table, the read-only schedule header/targets split, and the per-agent
power-intent registry:
Three tables, all in one file — the message queue, a small key/value
table, and the per-agent power-intent registry:
- `messages` — every inter-agent / operator-authored message.
`sender / recipient / body / sent_at / delivered_at / acked_at /
@ -54,17 +53,6 @@ power-intent registry:
per-agent store — see [`/harness/` contents
below](#state-dirs-per-agent) for where reminders (and todos)
live.
- `scheduled_prompts` — the scheduled prompts left from before they
moved to swarm level ([`swarm/schedules.md`](../swarm/schedules.md)).
`owner / body / interval_seconds (NULL = one-shot) /
next_fire_at_unix / created_at_unix / source ("operator" or
"approval:<id>") / cancelled_at_unix / description / paused_at_unix`.
Read-only: the dashboard's SCH3DUL3S tab lists them so the operator
can recreate them with `swarmctl schedule`; nothing writes or fires
them.
- `scheduled_prompt_targets` — per-target state for each schedule.
`schedule_id / target / cancelled_at_unix /
last_fired_at_unix / last_result`.
- `agent_power` — one row per agent: `agent PK / wanted (up |
offline) / updated_at`, owned by `hive-c0re/src/stores/power.rs`.
This is the durable power *intent* the job queue reconciles the
@ -74,6 +62,12 @@ power-intent registry:
section](../scheduler/coordinator.md#desired-state-spec-vs-status) for who
writes and reads it and how reconciliation works.
On a host whose hive-c0re once kept scheduled prompts, the file also
holds `scheduled_prompts` and `scheduled_prompt_targets`. Nothing opens
them; scheduled prompts live at swarm level
([`swarm/schedules.md`](../swarm/schedules.md)). They're safe to drop by
hand.
Retention:
- `Broker::vacuum_delivered` runs hourly via a tokio task in
@ -82,7 +76,6 @@ Retention:
rows are always kept — the harness `ack_turn`s only after a
successful turn, so `requeue_inflight` can still requeue
an unacked row on a crash.
- Scheduled prompts: nothing deletes them; the rows stay as they are.
- `agent_power` rows live until the operator destroys the agent (one row per
agent — nothing to vacuum).

View file

@ -16,13 +16,11 @@ Settings, Core). Every page links back to H0M3, so you're
never more than one step from the hub.
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
of your time. It's a single page with exactly three tabs:
of your time. It's a single page with exactly two tabs:
- **SW4RM** — every agent, live. This is the default tab and the one
you'll check most.
- **P3RM1SS10NS** — what tools and system-level access each agent has.
- **SCH3DUL3S** — a read-only list of the scheduled prompts left in the
hive, due for removal; operators set schedules at swarm level now.
Everything else lives on its own page instead, all reachable from the
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
@ -69,9 +67,8 @@ swarm's VictoriaLogs; read them in Grafana's **Explore** — see
[`swarm/services.md`](../swarm/services.md#logs-victorialogs).
**Set up a schedule.** Scheduled prompts are swarm-level: set them with
`swarmctl schedule` ([`swarm/schedules.md`](../swarm/schedules.md)).
SCH3DUL3S only lists the ones still in the hive, so you can copy them
over.
`swarmctl schedule` ([`swarm/schedules.md`](../swarm/schedules.md)) or
on the swarm UI's schedules page ([`swarm/ui.md`](../swarm/ui.md)).
## More depth

View file

@ -11,7 +11,7 @@ matrix accounts, the controller's job graph — are on the
hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`).
It has a fixed chrome header at the top and a `<main>` that shows exactly
one tab pane at a time. The URL hash (`#swarm`,
`#permissions`, `#schedules`) drives which pane is
`#permissions`) drives which pane is
active; hash changes don't reload the page. FL0W and the other
standalone pages are reachable from the H0M3 hub at `/`, not
from the dashboard tab strip.
@ -19,12 +19,12 @@ from the dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane):
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
at `/`. Every surface links back to the hub rather than to each other.
- **Tab strip**: `◆ SW4RM ◆`, `◆ P3RM1SS10NS ◆`,
`◆ SCH3DUL3S ◆`. In-page tabs only — the SYST3M panels live on the
- **Tab strip**: `◆ SW4RM ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only —
the SYST3M panels live on the
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
live on their own pages too, all reachable from the
H0M3 hub (not the tab strip). Count pills on SW4RM
(container count) and SCH3DUL3S (active schedules left to copy).
H0M3 hub (not the tab strip). Count pill on SW4RM
(container count).
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip.
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
@ -326,34 +326,6 @@ Live `capabilities_changed` / `tool_groups_changed` events re-render the
matrices unless the section has unsaved edits, so an in-progress edit set
isn't clobbered.
## SCH3DUL3S tab
A read-only list of the scheduled prompts still in the hive's store.
The hive no longer fires them: scheduled prompts live at swarm level
([`swarm/schedules.md`](../swarm/schedules.md)), and a warning banner at
the top of the tab says the page is due for removal and points at
`swarmctl schedule`. The list is there so the operator can recreate each
row with `swarmctl schedule add`.
**Single-table layout**: each schedule is one `<tr>`; columns are
`# | src | next | every | owner | body | …agents…`. Agent columns are
dynamic — every live container plus any extra name that appears as a
target on some schedule but isn't a current container (struck through
as gone). Column headers render vertically so each column reads as a
narrow ~28px strip; per-agent cells show `✓` for an active target, a
muted `✕` for a cancelled one, and nothing when the agent isn't a
target. Hovering a target cell shows its last fire and result.
The `next` column shows the stored next fire time as an absolute UTC
timestamp, the value to pass as `--at`; paused and cancelled rows say
so instead, with the time in the cell's tooltip. The body cell
truncates; hovering shows the full body and description.
The tab has no controls: nothing creates, edits, pauses, fires or
cancels a row. The tab pill shows the count of active schedules (at least one
live target not yet cancelled). Fetched from `GET /api/schedules` on
tab activation and on each dashboard state load.
## ST4TS page (`/stats.html`)
Hive-wide turn statistics, aggregated across every agent's
@ -843,9 +815,6 @@ below — some endpoints aren't in it yet.
rows with both fields omitted rather than erroring on them.
Returns `200 "ok"` on
success.
- `GET /api/schedules` — list every schedule row left in the hive's
store (cancelled ones included) for the read-only SCH3DUL3S tab.
Targets naming an agent that no longer exists are left out.
- `POST /api/meta-update` — `nix flake update` the selected
`meta/flake.lock` inputs, then rebuild the affected agents.
- `GET /api/dashboard/stream` — unified live event channel:
@ -892,9 +861,7 @@ payload):
reminder mutation: agent `remind` calls (`agent_server`),
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
with Reminder kind, and the scheduler tick after each delivery
batch (`reminder_scheduler`). The SCH3DUL3S tab's reminders section
subscribes and calls `renderReminders` on receipt, so the list
updates live without polling.
batch (`reminder_scheduler`).
- `capabilities_changed` (seq, caps: `Vec<str>`, descriptions: map,
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
capability grants. Emitted from the rebuild-queue worker after a

View file

@ -208,7 +208,7 @@ re-render), call `refreshState()`. State shapes live in
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
`web_ui/state.rs::StateSnapshot` — when
adding state fields, plumb through the snapshot struct and the
relevant domain module (`swarm.js`, `schedules.js`, etc.) render function.
relevant domain module (`swarm.js`, `permissions.js`, etc.) render function.
**Focus preservation:** `refreshState` checks whether
`document.activeElement` sits inside one of the managed sections

View file

@ -4,8 +4,8 @@
/* SYST3M panel rules (rebuild queue / meta inputs / container load)
live in system-sections.css, shared with /core.html. A few are still
referenced from dashboard JS (the schedules view uses `.rqe-source*`),
so the dashboard pulls them in here. */
referenced from the dashboard (`.rebuild-queue`), so the dashboard
pulls them in here. */
@import "./system-sections.css";
@import "@hive/shared/jobq-rollup.css";
@ -709,113 +709,6 @@ footer .banner-thin {
cursor: not-allowed;
}
/* ─── scheduled prompts tab ────────────────────────────────────────
Read-only table of the schedules left in the hive's store. */
.schedules-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.schedules-table {
min-width: 100%;
border-collapse: collapse;
font-size: 0.82em;
}
.schedules-table th,
.schedules-table td {
padding: 0.35em 0.6em;
border: 1px solid var(--border);
vertical-align: middle;
}
.schedules-table thead th {
background: var(--bg-elev);
color: var(--muted);
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.schedules-table-id {
width: 3em;
text-align: center;
}
.schedules-table-next-col {
white-space: nowrap;
}
/* every column: sized to typical content (short durations). */
.schedules-table-every-col {
width: 5em;
white-space: nowrap;
}
.schedules-table-body-th {
min-width: 12em;
}
/* Agent-name columns: 90° vertical text (writing-mode) so names are fully
readable without truncation. Previously used a -45° CSS transform which
clipped names mid-glyph. writing-mode + rotate(180deg) is in-flow and
needs no absolute positioning or overflow hacks. */
.schedules-table-agent-th {
width: 28px;
min-width: 28px;
height: 8em;
padding: 4px 2px !important;
vertical-align: bottom;
text-transform: none !important;
letter-spacing: 0 !important;
overflow: hidden;
}
.schedules-table-agent-th > div {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.85em;
color: var(--fg);
/* Rotate 90° bottom-to-top (conventional rotated column header). */
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-height: 7.5em;
}
.schedules-table-agent-th > div > span {
display: inline-block;
padding: 0 2px;
}
/* Column for an agent no longer in the live roster — its past schedules
linger but the agent is gone. Greyed + struck through. */
.schedules-table-agent-th--gone > div {
color: var(--muted);
text-decoration: line-through;
opacity: 0.75;
}
.schedules-table-row-cancelled td {
opacity: 0.55;
}
.schedules-table-row-paused td {
opacity: 0.75;
}
.sched-paused-label {
color: var(--yellow);
font-size: 0.9em;
}
.schedules-table-body-cell {
max-width: 30em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.9em;
}
.schedules-table-check {
width: 28px;
text-align: center;
color: var(--green);
padding: 0.3em 0 !important;
}
.schedules-table-check-cancelled {
color: var(--muted);
font-size: 0.9em;
}
/* Selection bar — sticky-bottom strip for bulk actions. */
.selection-bar {
position: fixed;

View file

@ -53,21 +53,6 @@
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<!-- SCH3DUL3S: read-only scheduled-prompts list. Count pill
mirrors the active (non-cancelled) schedule count; hidden
when zero. -->
<a
class="tab"
id="tab-schedules"
href="#schedules"
role="tab"
aria-controls="tab-pane-schedules"
data-tab="schedules"
>
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only
view, not part of the operational tab strip. -->
@ -190,38 +175,6 @@
</div>
</section>
<!-- SCH3DUL3S: read-only list of the scheduled prompts left in the
hive's store, driven by GET /api/schedules (re-fetched on tab
activation). Nothing on the hive fires them; the banner points
at `swarmctl schedule`, where schedules live now.
See docs/web-ui/dashboard.md::SCH3DUL3S tab. -->
<section
class="tab-pane"
id="tab-pane-schedules"
data-tab-pane="schedules"
hidden
role="tabpanel"
aria-labelledby="tab-schedules"
>
<h2>◆ SCH3DUL3S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="server-warn server-warn-warn" role="note">
⚠ this page is slated for removal. the hive no longer fires these
schedules — they live at swarm level now: recreate each one with
<code>swarmctl schedule add</code> (see
<code>docs/swarm/schedules.md</code>).
</p>
<p class="meta">
read-only: every schedule still in the hive's table, kept so it can
be copied over.
</p>
<div id="schedules-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on the

View file

@ -42,7 +42,7 @@
<span class="home-tile-label">Dashboard</span>
</span>
<span class="home-tile-desc"
>containers · permissions · schedules · system</span
>containers · permissions · system</span
>
</a>

View file

@ -1,232 +0,0 @@
// Dashboard SCH3DUL3S tab — read-only list of the scheduled prompts left
// in the hive's store, so the operator can recreate them with
// `swarmctl schedule`. Nothing on the hive fires or edits them; the list
// is fetched from `/api/schedules` on tab activation and state loads.
//
// Owns its own module state (the schedules list); reads the shared agent
// roster from state.js for the per-agent columns. Render + format helpers
// come from util.js.
//
// Note: per-agent reminders live in the in-container store, not here —
// they surface via get_loose_ends / the todos pill on the agent page.
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from "./util.js";
import { containersState } from "./state.js";
// Local cache lets `refreshTabCounts` show the active count without
// re-fetching every second.
let schedulesState = [];
export async function refreshSchedules() {
const listRoot = $("schedules-section");
if (!listRoot) return;
try {
const resp = await fetch("/api/schedules");
if (!resp.ok) {
paintAtomic(listRoot, (root) => {
root.append(
el(
"p",
{ class: "empty" },
"schedules unavailable: http " + resp.status,
),
);
});
return;
}
schedulesState = await resp.json();
} catch (err) {
paintAtomic(listRoot, (root) => {
root.append(
el("p", { class: "empty" }, "schedules fetch failed: " + err),
);
});
return;
}
renderSchedulesList();
}
// Active = at least one target still alive (no `cancelled_at_unix`)
// AND the whole schedule isn't cancelled. Drives the tab pill count.
export function activeScheduleCount() {
let n = 0;
for (const s of schedulesState) {
if (s.cancelled_at_unix) continue;
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
}
return n;
}
// Table layout: one row per schedule, attribute columns + one ✓/✕
// column per agent (vertical header so a row of agents takes ~28px
// each instead of full word width).
function renderSchedulesList() {
const liveRoot = $("schedules-section");
if (!liveRoot) return;
paintAtomic(liveRoot, (root) => {
if (!schedulesState.length) {
root.append(el("p", { class: "empty" }, "no schedules left"));
return;
}
const agents = schedulesTableAgentSet();
const table = el("table", { class: "schedules-table" });
table.append(renderSchedulesTableHead(agents));
const tbody = el("tbody", {});
const sorted = schedulesState.slice().sort((a, b) => {
// Cancelled → bucket 2, paused → bucket 1, active → bucket 0.
const aOrd = a.cancelled_at_unix ? 2 : a.paused_at_unix ? 1 : 0;
const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0;
if (aOrd !== bOrd) return aOrd - bOrd;
return epochSec(a.next_fire_at_unix) - epochSec(b.next_fire_at_unix);
});
for (const s of sorted) tbody.append(renderScheduleRow(s, agents));
table.append(tbody);
const wrap = el("div", { class: "schedules-table-wrap" });
wrap.append(table);
root.append(wrap);
});
}
// The set of agent columns in the schedules table: live containers
// (sorted), then any extra names that appear as a schedule target but
// aren't in the live container list.
function schedulesTableAgentSet() {
const seen = new Set();
const out = [];
const push = (n) => {
if (!seen.has(n)) {
seen.add(n);
out.push(n);
}
};
const containerNames = Array.from(containersState.values())
.map((c) => c.name)
.filter((n) => n !== "operator")
.sort();
for (const n of containerNames) push(n);
for (const s of schedulesState) {
for (const t of s.targets || []) push(t.target);
}
return out;
}
function renderSchedulesTableHead(agents) {
const thead = el("thead", {});
const headerRow = el("tr", {});
headerRow.append(
el("th", { class: "schedules-table-id" }, "#"),
el("th", {}, "src"),
el("th", { class: "schedules-table-next-col" }, "next"),
el("th", { class: "schedules-table-every-col" }, "every"),
el("th", {}, "owner"),
el("th", { class: "schedules-table-body-th" }, "body"),
);
// Every column maps to a container, so flag any column whose agent is
// no longer in the live roster — its past schedules linger in the
// table but the agent is gone.
const liveNames = new Set(
Array.from(containersState.values()).map((c) => c.name),
);
for (const a of agents) {
const gone = !liveNames.has(a);
headerRow.append(
el(
"th",
{
class:
"schedules-table-agent-th" +
(gone ? " schedules-table-agent-th--gone" : ""),
title: gone ? a + " — no longer exists (gone)" : a,
},
el("div", {}, el("span", {}, a)),
),
);
}
thead.append(headerRow);
return thead;
}
function renderScheduleRow(s, agents) {
const cancelled = !!s.cancelled_at_unix;
const paused = !cancelled && !!s.paused_at_unix;
const tr = el("tr", {
class:
"schedules-table-row" +
(cancelled ? " schedules-table-row-cancelled" : "") +
(paused ? " schedules-table-row-paused" : ""),
});
tr.append(el("td", { class: "meta schedules-table-id" }, "#" + s.id));
const srcKind =
s.source && s.source.kind === "approval" ? "approval" : "manual";
tr.append(
el(
"td",
{},
el(
"span",
{ class: "rqe-source rqe-source-" + srcKind },
srcKind === "approval" ? "approval" : "operator",
),
),
);
// "next" cell — the stored next fire time, absolute: it is the value
// to pass as `swarmctl schedule add --at`. Cancelled / paused rows say
// so, with the time in the title.
const nextIso = new Date(s.next_fire_at_unix).toISOString();
const nextCell = el("td", { class: "meta schedules-table-next-col" });
if (cancelled) {
nextCell.title = "cancelled " + new Date(s.cancelled_at_unix).toISOString();
nextCell.textContent = "cancelled";
} else if (paused) {
nextCell.title =
"paused since " +
new Date(s.paused_at_unix).toISOString() +
"\nnext fire " +
nextIso;
nextCell.append(el("span", { class: "sched-paused-label" }, "⏸ paused"));
} else {
nextCell.textContent = nextIso;
}
tr.append(nextCell);
tr.append(
el(
"td",
{ class: "meta schedules-table-every-col" },
s.interval_seconds ? "↻ " + fmtDuration(s.interval_seconds) : "one-shot",
),
);
tr.append(el("td", { class: "meta" }, s.owner));
// Body cell — truncates with ellipsis; full body + description on hover.
const bodyCell = el("td", { class: "schedules-table-body-cell" });
const bodyText = s.body || "";
bodyCell.title = (s.description ? s.description + "\n\n" : "") + bodyText;
bodyCell.textContent = bodyText;
tr.append(bodyCell);
// Per-agent target cells: ✓ active target, muted ✕ cancelled target,
// empty when the agent isn't a target. Last fire + result on hover.
const targetByName = new Map();
for (const t of s.targets || []) targetByName.set(t.target, t);
for (const a of agents) {
const t = targetByName.get(a);
const td = el("td", { class: "schedules-table-check" });
if (t) {
td.title =
(t.cancelled_at_unix ? "cancelled — " : "") +
(t.last_fired_at_unix
? "last fired " + fmtAgo(t.last_fired_at_unix) + " ago"
: "never fired") +
(t.last_result ? " · " + t.last_result : "");
td.append(
t.cancelled_at_unix
? el("span", { class: "schedules-table-check-cancelled" }, "✕")
: el("span", {}, "✓"),
);
}
tr.append(td);
}
return tr;
}

View file

@ -2,7 +2,7 @@
// dashboard SPA: the live agent roster.
//
// The roster is read by nearly every tab (the container tree, the
// capabilities + tool-group matrices, the schedule target-chips, the
// capabilities + tool-group matrices, the
// container-load poll, and the selection bar), so
// it lives here as a single source of truth that each domain module
// imports rather than threading through call signatures. Everything

View file

@ -122,34 +122,6 @@
color: var(--amber);
font-weight: bold;
}
.rqe-source {
font-size: 0.75em;
padding: 0.05em 0.45em;
border-radius: 0.7em;
border: 1px solid var(--border);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rqe-source-manual {
color: var(--cyan);
border-color: var(--cyan);
}
.rqe-source-meta_update {
color: var(--purple);
border-color: var(--purple);
}
.rqe-source-auto_update {
color: var(--muted);
}
.rqe-source-crash_recover {
color: var(--amber);
border-color: var(--amber);
}
.rqe-source-approval {
color: var(--green);
border-color: var(--green);
}
/* ─── running-rebuild live log (renderRebuildLiveLog in builds.js) ──────────
Streams the currently-running rebuild's build log inline under the queue.
Hidden (native `hidden` attr) when nothing is building. Used on

View file

@ -23,7 +23,6 @@ import {
fetchAndRenderToolGroups,
initPermissions,
} from "./permissions.js";
import { refreshSchedules, activeScheduleCount } from "./schedules.js";
import {
initJobqRollup,
syncTransientsFromSnapshot,
@ -56,7 +55,6 @@ window.marked = marked;
const MANAGED_SECTION_IDS = [
"containers-section",
"inbox-section",
"schedules-section",
"capabilities-section",
"tool-groups-section",
];
@ -149,7 +147,6 @@ window.marked = marked;
// mount's own effect handles the actual fetch.
initJobqRollup();
renderContainers(s);
refreshSchedules();
restoreOpenDetails(openDetails);
// No periodic refresh timer. Phase 6 covers every container
// mutation with `ContainerStateChanged` / `ContainerRemoved`
@ -248,8 +245,8 @@ window.marked = marked;
})();
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#swarm` / `#system` / `#permissions` /
// `#schedules` activate the matching pane on the dashboard. Empty
// Hash-based: `#swarm` / `#system` / `#permissions` activate the
// matching pane on the dashboard. Empty
// hash defaults to SW4RM. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from
// the H0M3 hub. Tab routing only
@ -266,11 +263,6 @@ window.marked = marked;
// visibility (bar only belongs on SW4RM where agent cards live).
document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values()));
// Re-fetch on activation as a safety net: SSE covers live mutations,
// re-sync covers disconnect windows.
if (target === "schedules") {
refreshSchedules();
}
// Permissions tables: SSE covers worker-applied changes
// (capabilities_changed / tool_groups_changed); re-fetch on
// activation as a safety net for any gap between SSE events and
@ -310,9 +302,6 @@ window.marked = marked;
if (c.needs_update) swarm++;
}
setTabCount("swarm", swarm);
// SCH3DUL3S — count of schedules with at least one still-active
// target: the ones left to recreate with `swarmctl schedule`.
setTabCount("schedules", activeScheduleCount());
}
// Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE

View file

@ -1,7 +1,7 @@
// Shared dashboard render + format helpers.
//
// Small pure utilities used across most dashboard tabs (the container
// tree, the rebuild queue, schedules + reminders):
// tree, the rebuild queue):
// an atomic-swap render helper and a handful of relative-time / duration
// formatters. They live here — a dashboard-internal module — rather than
// in the cross-page `common.js`, because they're specific to the

View file

@ -29,10 +29,6 @@ const LAST_STOPPED_RUNNING_KEY: &str = "last_stopped_running";
pub struct Coordinator {
pub broker: Arc<Broker>,
/// Scheduled-prompt rows, read-only: the dashboard lists them so the
/// operator can recreate them with `swarmctl schedule`. Nothing on
/// the hive writes or fires them.
pub scheduled_prompts: Arc<crate::scheduled_prompts::ScheduledPrompts>,
/// Full build-log capture. `lifecycle::run` /
/// `lifecycle::prebuild_toplevel` `start()` a row per attempt,
/// pipe every stdout/stderr line into it, and `finish()` it on
@ -452,8 +448,6 @@ impl Coordinator {
agent_io_weight,
} = env;
let broker = Broker::open(db_path).context("open broker")?;
let scheduled_prompts = crate::scheduled_prompts::ScheduledPrompts::open(db_path)
.context("open scheduled_prompts")?;
// BuildLogs wants a directory (it picks its own `build_logs.sqlite`
// file under it); every other opener here takes the sibling
// sqlite-file path itself. Derive the dir from `db_path`'s
@ -481,7 +475,6 @@ impl Coordinator {
let (shutdown_tx, _) = watch::channel(false);
Ok(Self {
broker: Arc::new(broker),
scheduled_prompts: Arc::new(scheduled_prompts),
build_logs,
hyperhive_flake,
hyperhive_docs_flake,

View file

@ -38,7 +38,7 @@ use crate::lifecycle;
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "schedules", description = "read-only scheduled-prompt list + rebuild-queue cancel"),
(name = "rebuild_queue", description = "rebuild-queue cancel"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
(name = "tombstones", description = "purge of retained state for destroyed agents"),
@ -57,7 +57,7 @@ mod lifecycle_ops;
mod meta_inputs;
mod misc_api;
pub(crate) mod permissions;
mod schedules;
mod rebuild_queue;
mod state_files;
mod state_snapshot;
mod tombstones;
@ -132,8 +132,7 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(permissions::post_permissions))
.routes(routes!(permissions::get_stale_permissions))
.routes(routes!(permissions::delete_agent_permissions))
.routes(routes!(schedules::api_schedules))
.routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
.routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill))
.routes(routes!(lifecycle_ops::post_restart))
@ -389,8 +388,7 @@ mod router_build_probe {
.routes(routes!(permissions::post_permissions))
.routes(routes!(permissions::get_stale_permissions))
.routes(routes!(permissions::delete_agent_permissions))
.routes(routes!(schedules::api_schedules))
.routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
.routes(routes!(lifecycle_ops::post_destroy))
.routes(routes!(lifecycle_ops::post_kill))
.routes(routes!(lifecycle_ops::post_restart))

View file

@ -0,0 +1,49 @@
//! Rebuild-queue cancel endpoint for the dashboard.
use axum::{
extract::{Path as AxumPath, State},
response::{IntoResponse, Response},
};
use super::AppState;
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(super) struct CancelResultBody {
cancelled: bool,
}
/// Drop still-queued work from the job
/// queue.
///
/// `id` is a **node** id. A DAG's root cancels the whole group (the scheduler
/// cascades to pending descendants), which is what the dashboard's cancel
/// button sends today — a DAG id *is* its root node's id. An interior node
/// cancels just that branch.
///
/// Refuses `Running` / terminal nodes: an
/// in-flight node owns the agent's nix store + nixos-container update
/// lock and can't be safely interrupted from the queue side. Always
/// returns 200; the body is `{"cancelled": true}` on a successful
/// flip to Cancelled, `{"cancelled": false}` when the DAG was
/// Running / terminal / gone. On success a fresh `RebuildQueueChanged`
/// snapshot fires so the state flip surfaces live.
#[utoipa::path(
post,
path = "/api/rebuild-queue/{id}/cancel",
params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")),
responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)),
tag = "rebuild_queue"
)]
pub(super) async fn post_rebuild_queue_cancel(
State(state): State<AppState>,
AxumPath(id): AxumPath<u64>,
) -> Response {
if state.coord.job_queue.cancel(id) {
// Any terminal side effect is the DAG's own spared tail node, which the
// scheduler picks up on its next pass — nothing to fire from here.
state.coord.emit_rebuild_queue_snapshot();
axum::Json(CancelResultBody { cancelled: true }).into_response()
} else {
axum::Json(CancelResultBody { cancelled: false }).into_response()
}
}

View file

@ -1,190 +0,0 @@
//! Scheduled-prompt list + rebuild-queue cancel endpoints for the dashboard.
//!
//! `/api/schedules` is read-only: it lists the rows still in the hive's
//! `scheduled_prompts` table so the operator can recreate them with
//! `swarmctl schedule` (`docs/swarm/schedules.md`). Nothing on the hive
//! writes or fires those rows. Also the co-located
//! `/api/rebuild-queue/{id}/cancel` endpoint.
use axum::{
extract::{Path as AxumPath, State},
response::{IntoResponse, Response},
};
use super::{AppState, error_response};
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(super) struct CancelResultBody {
cancelled: bool,
}
/// Snapshot of every schedule row for the
/// scheduled-prompts tab.
///
/// Returns the wire shape directly so the frontend can render
/// without an extra translation layer.
// `hive_sh4re::schedule::WireSchedule` (the actual body) has no `ToSchema` — adding
// one would pull `utoipa` into the wire-types crate for a single dashboard
// endpoint. `serde_json::Value` placeholder.
#[utoipa::path(
get,
path = "/api/schedules",
responses(
(status = 200, description = "every schedule, wire shape", body = Vec<serde_json::Value>),
(status = 500, description = "sqlite read failed"),
),
tag = "schedules"
)]
pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
match state.coord.scheduled_prompts.list() {
Ok(rows) => {
let mut wire: Vec<hive_sh4re::schedule::WireSchedule> =
rows.into_iter().map(schedule_to_wire).collect();
let live: std::collections::HashSet<String> = state
.coord
.containers_snapshot()
.await
.into_iter()
.map(|c| c.name)
.collect();
filter_ghost_schedule_targets(&mut wire, &live);
axum::Json(wire).into_response()
}
Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")),
}
}
/// Drop schedule targets that point at agents which no longer exist, so
/// the schedule table doesn't render ghost columns for destroyed agents.
/// `live` is the set of logical agent names from the last
/// `nixos-container list` scan (stopped agents included, destroyed ones
/// absent). A view filter: the rows keep every target.
fn filter_ghost_schedule_targets(
schedules: &mut [hive_sh4re::schedule::WireSchedule],
live: &std::collections::HashSet<String>,
) {
for s in schedules.iter_mut() {
s.targets.retain(|t| live.contains(&t.target));
}
}
fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::schedule::WireSchedule {
hive_sh4re::schedule::WireSchedule {
id: s.id,
owner: s.owner,
body: s.body,
interval_seconds: s.interval_seconds,
next_fire_at_unix: s.next_fire_at_unix,
created_at_unix: s.created_at_unix,
source: match s.source {
crate::scheduled_prompts::ScheduleSource::Operator => {
hive_sh4re::schedule::WireScheduleSource::Operator
}
crate::scheduled_prompts::ScheduleSource::Approval { id } => {
hive_sh4re::schedule::WireScheduleSource::Approval { id }
}
},
cancelled_at_unix: s.cancelled_at_unix,
paused_at_unix: s.paused_at_unix,
description: s.description,
targets: s
.targets
.into_iter()
.map(|t| hive_sh4re::schedule::WireScheduleTarget {
target: t.target,
cancelled_at_unix: t.cancelled_at_unix,
last_fired_at_unix: t.last_fired_at_unix,
last_result: t.last_result,
})
.collect(),
}
}
/// Drop still-queued work from the job
/// queue.
///
/// `id` is a **node** id. A DAG's root cancels the whole group (the scheduler
/// cascades to pending descendants), which is what the dashboard's cancel
/// button sends today — a DAG id *is* its root node's id. An interior node
/// cancels just that branch.
///
/// Refuses `Running` / terminal nodes: an
/// in-flight node owns the agent's nix store + nixos-container update
/// lock and can't be safely interrupted from the queue side. Always
/// returns 200; the body is `{"cancelled": true}` on a successful
/// flip to Cancelled, `{"cancelled": false}` when the DAG was
/// Running / terminal / gone. On success a fresh `RebuildQueueChanged`
/// snapshot fires so the state flip surfaces live.
#[utoipa::path(
post,
path = "/api/rebuild-queue/{id}/cancel",
params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")),
responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)),
tag = "schedules"
)]
pub(super) async fn post_rebuild_queue_cancel(
State(state): State<AppState>,
AxumPath(id): AxumPath<u64>,
) -> Response {
if state.coord.job_queue.cancel(id) {
// Any terminal side effect is the DAG's own spared tail node, which the
// scheduler picks up on its next pass — nothing to fire from here.
state.coord.emit_rebuild_queue_snapshot();
axum::Json(CancelResultBody { cancelled: true }).into_response()
} else {
axum::Json(CancelResultBody { cancelled: false }).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target(name: &str) -> hive_sh4re::schedule::WireScheduleTarget {
hive_sh4re::schedule::WireScheduleTarget {
target: name.to_owned(),
cancelled_at_unix: None,
last_fired_at_unix: None,
last_result: None,
}
}
fn schedule(targets: &[&str]) -> hive_sh4re::schedule::WireSchedule {
hive_sh4re::schedule::WireSchedule {
id: 1,
owner: "operator".to_owned(),
body: "ping".to_owned(),
interval_seconds: None,
next_fire_at_unix: hive_sh4re::wire_time::from_secs(0),
created_at_unix: hive_sh4re::wire_time::from_secs(0),
source: hive_sh4re::schedule::WireScheduleSource::Operator,
cancelled_at_unix: None,
paused_at_unix: None,
description: None,
targets: targets.iter().map(|t| target(t)).collect(),
}
}
#[test]
fn ghost_filter_drops_dead_agents_keeps_live() {
let live: std::collections::HashSet<String> = ["iris".to_owned(), "damocles".to_owned()]
.into_iter()
.collect();
let mut schedules = vec![schedule(&["iris", "ghost", "damocles"])];
filter_ghost_schedule_targets(&mut schedules, &live);
let kept: Vec<&str> = schedules[0]
.targets
.iter()
.map(|t| t.target.as_str())
.collect();
assert_eq!(kept, vec!["iris", "damocles"]);
}
#[test]
fn ghost_filter_can_empty_targets_when_all_dead() {
let live: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut schedules = vec![schedule(&["gone1", "gone2"])];
filter_ghost_schedule_targets(&mut schedules, &live);
assert!(schedules[0].targets.is_empty());
}
}

View file

@ -45,7 +45,7 @@ mod workers;
pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology};
pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings};
pub(crate) use stores::{broker, build_logs, db, power, scheduled_prompts};
pub(crate) use stores::{broker, build_logs, db, power};
pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets};
use coordinator::{Coordinator, HiveEnv, ServeConfig};

View file

@ -1,10 +1,8 @@
//! Sqlite-backed host-side stores (broker, read-only scheduled prompts,
//! build logs, power intent) plus the shared connection open/migration
//! helper (`db`). Each submodule is re-exported at the crate root, so
//! Sqlite-backed host-side stores (broker, build logs, power intent)
//! plus the shared connection open/migration helper (`db`). Each submodule is re-exported at the crate root, so
//! `crate::broker::…` etc. keep working unchanged.
pub mod broker;
pub mod build_logs;
pub mod db;
pub mod power;
pub mod scheduled_prompts;

View file

@ -5,9 +5,8 @@
//!
//! Stored as the `agent_power` table in the coordinator DB
//! (`/var/lib/hyperhive/db/broker.sqlite`, one tiny row per agent) —
//! same one-file-many-modules pattern as `broker` /
//! `scheduled_prompts`, each with its own connection. Intent persists
//! across hive-c0re restarts; in-flight
//! same one-file-many-modules pattern as `broker`, each with its own
//! connection. Intent persists across hive-c0re restarts; in-flight
//! queue work deliberately does not. Setting `wanted` is never a
//! queued node: operator/intent actions update the row synchronously
//! at request time, then submit the DAG whose terminal `Reconcile`
@ -72,7 +71,7 @@ pub struct PowerStore {
impl PowerStore {
/// Open (a connection to) the shared coordinator DB and ensure the
/// `agent_power` table exists. `db_path` is the same sqlite file
/// the broker / scheduled-prompts stores open.
/// the broker store opens.
pub fn open(db_path: &Path) -> Result<Self> {
let conn = crate::db::open(db_path, "agent_power")?;
conn.execute_batch(SCHEMA)

View file

@ -1,225 +0,0 @@
//! Scheduled prompts left in the hive's sqlite: the rows the dashboard's
//! read-only schedule list shows so the operator can recreate them with
//! `swarmctl schedule` (`docs/swarm/schedules.md`). Nothing on the hive
//! writes or fires these rows.
//!
//! Schema: `docs/agent-lifecycle/persistence.md::/var/lib/hyperhive/db/broker.sqlite`
//! (the `scheduled_prompts` / `scheduled_prompt_targets` table bullets).
use std::path::Path;
use std::sync::Mutex;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use crate::db::Migration;
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS scheduled_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
body TEXT NOT NULL,
interval_seconds INTEGER,
next_fire_at_unix INTEGER NOT NULL,
created_at_unix INTEGER NOT NULL,
source TEXT NOT NULL,
cancelled_at_unix INTEGER,
description TEXT,
paused_at_unix INTEGER
);
CREATE TABLE IF NOT EXISTS scheduled_prompt_targets (
schedule_id INTEGER NOT NULL,
target TEXT NOT NULL,
cancelled_at_unix INTEGER,
last_fired_at_unix INTEGER,
last_result TEXT,
PRIMARY KEY (schedule_id, target),
FOREIGN KEY (schedule_id) REFERENCES scheduled_prompts(id) ON DELETE CASCADE
);
";
/// One scheduled-prompt row + its target set, as `list` returns it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Schedule {
pub id: i64,
/// `"operator"` or the name of the agent whose approved request
/// created the row.
pub owner: String,
pub body: String,
/// `None` = one-shot. `Some(n)` = recurring every `n` seconds.
pub interval_seconds: Option<u64>,
pub next_fire_at_unix: DateTime<Utc>,
pub created_at_unix: DateTime<Utc>,
pub source: ScheduleSource,
/// Set when the whole schedule was cancelled.
pub cancelled_at_unix: Option<DateTime<Utc>>,
pub description: Option<String>,
pub paused_at_unix: Option<DateTime<Utc>>,
pub targets: Vec<ScheduleTarget>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleTarget {
pub target: String,
pub cancelled_at_unix: Option<DateTime<Utc>>,
pub last_fired_at_unix: Option<DateTime<Utc>>,
pub last_result: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ScheduleSource {
Operator,
Approval { id: i64 },
}
impl ScheduleSource {
fn from_db_string(s: &str) -> Self {
if let Some(rest) = s.strip_prefix("approval:")
&& let Ok(id) = rest.parse::<i64>()
{
return ScheduleSource::Approval { id };
}
// Unknown / "operator" / corrupt → operator (best the row
// can do without an unparseable-source variant).
ScheduleSource::Operator
}
}
pub struct ScheduledPrompts {
conn: Mutex<Connection>,
}
impl ScheduledPrompts {
pub fn open(path: &Path) -> Result<Self> {
let conn = crate::db::open(path, "scheduled_prompts")?;
conn.execute_batch(SCHEMA)
.context("apply scheduled_prompts schema")?;
// The per-migration `adds_column` guard skips the ALTER on a legacy
// DB that already has the column.
crate::db::apply_versioned_migrations(
&conn,
"scheduled_prompts",
&[
// v1: add paused_at_unix.
Migration {
sql: "ALTER TABLE scheduled_prompts ADD COLUMN paused_at_unix INTEGER",
adds_column: Some(("scheduled_prompts", "paused_at_unix")),
},
],
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Every row, cancelled ones included, ordered by next fire time.
pub fn list(&self) -> Result<Vec<Schedule>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, owner, body, interval_seconds, next_fire_at_unix,
created_at_unix, source, cancelled_at_unix, description,
paused_at_unix
FROM scheduled_prompts
ORDER BY next_fire_at_unix ASC, id ASC",
)?;
let rows = stmt.query_map([], row_to_schedule_header)?;
let mut out = Vec::new();
for row in rows {
let mut s = row?;
s.targets = load_targets(&conn, s.id)?;
out.push(s);
}
Ok(out)
}
}
fn row_to_schedule_header(row: &rusqlite::Row) -> rusqlite::Result<Schedule> {
let interval: Option<i64> = row.get(3)?;
let source_str: String = row.get(6)?;
Ok(Schedule {
id: row.get(0)?,
owner: row.get(1)?,
body: row.get(2)?,
interval_seconds: interval.and_then(|i| u64::try_from(i).ok()),
next_fire_at_unix: hive_sh4re::wire_time::from_secs(row.get(4)?),
created_at_unix: hive_sh4re::wire_time::from_secs(row.get(5)?),
source: ScheduleSource::from_db_string(&source_str),
cancelled_at_unix: row
.get::<_, Option<i64>>(7)?
.map(hive_sh4re::wire_time::from_secs),
description: row.get(8)?,
paused_at_unix: row
.get::<_, Option<i64>>(9)?
.map(hive_sh4re::wire_time::from_secs),
targets: Vec::new(),
})
}
fn load_targets(conn: &Connection, schedule_id: i64) -> Result<Vec<ScheduleTarget>> {
let mut stmt = conn.prepare(
"SELECT target, cancelled_at_unix, last_fired_at_unix, last_result
FROM scheduled_prompt_targets
WHERE schedule_id = ?1
ORDER BY target ASC",
)?;
let rows = stmt.query_map(params![schedule_id], |row| {
Ok(ScheduleTarget {
target: row.get(0)?,
cancelled_at_unix: row
.get::<_, Option<i64>>(1)?
.map(hive_sh4re::wire_time::from_secs),
last_fired_at_unix: row
.get::<_, Option<i64>>(2)?
.map(hive_sh4re::wire_time::from_secs),
last_result: row.get(3)?,
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn list_returns_rows_with_their_targets() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("schedules.sqlite");
let db = ScheduledPrompts::open(&path).expect("open");
let raw = Connection::open(&path).expect("raw open");
raw.execute_batch(
"INSERT INTO scheduled_prompts
(id, owner, body, interval_seconds, next_fire_at_unix,
created_at_unix, source, description)
VALUES (1, 'operator', 'later', NULL, 200, 10, 'operator', NULL),
(2, 'iris', 'hourly', 3600, 100, 10, 'approval:7', 'tick');
INSERT INTO scheduled_prompt_targets (schedule_id, target, cancelled_at_unix)
VALUES (1, 'bob', NULL), (2, 'iris', NULL), (2, 'alice', 50);",
)
.expect("seed");
let rows = db.list().expect("list");
assert_eq!(rows.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2, 1]);
let hourly = &rows[0];
assert_eq!(hourly.interval_seconds, Some(3600));
assert_eq!(hourly.source, ScheduleSource::Approval { id: 7 });
assert_eq!(hourly.description.as_deref(), Some("tick"));
let targets: Vec<(&str, bool)> = hourly
.targets
.iter()
.map(|t| (t.target.as_str(), t.cancelled_at_unix.is_some()))
.collect();
assert_eq!(targets, vec![("alice", true), ("iris", false)]);
assert_eq!(rows[1].source, ScheduleSource::Operator);
assert_eq!(rows[1].interval_seconds, None);
}
}

View file

@ -7,7 +7,6 @@ pub mod inbox;
pub mod manager;
pub mod paths;
pub mod permissions;
pub mod schedule;
pub mod stream_enrich;
pub mod term_msg;
pub mod wire_time;

View file

@ -1,48 +0,0 @@
//! Scheduled-prompt row shape on the wire, served by the dashboard's
//! read-only schedule list (mirrors `hive-c0re`'s internal `scheduled_prompts::Schedule`).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Schedule row shape on the wire — mirror of
/// `scheduled_prompts::Schedule` but in the public crate so a client
/// can deserialize it without depending on hive-c0re-internal types.
/// Kept structurally identical to the in-process type; the conversion
/// is field-by-field in the dashboard.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireSchedule {
pub id: i64,
pub owner: String,
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>,
pub next_fire_at_unix: DateTime<Utc>,
pub created_at_unix: DateTime<Utc>,
pub source: WireScheduleSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cancelled_at_unix: Option<DateTime<Utc>>,
/// Set if the row is paused.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paused_at_unix: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub targets: Vec<WireScheduleTarget>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WireScheduleSource {
Operator,
Approval { id: i64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireScheduleTarget {
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cancelled_at_unix: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_fired_at_unix: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<String>,
}