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:
parent
2f05e2db28
commit
78412b3d3b
22 changed files with 84 additions and 979 deletions
|
|
@ -38,9 +38,8 @@ schemas, file layouts, and internal migration mechanics.
|
||||||
|
|
||||||
### `/var/lib/hyperhive/db/broker.sqlite` (host)
|
### `/var/lib/hyperhive/db/broker.sqlite` (host)
|
||||||
|
|
||||||
Five tables, all in one file — the message queue, a small key/value
|
Three tables, all in one file — the message queue, a small key/value
|
||||||
table, the read-only schedule header/targets split, and the per-agent
|
table, and the per-agent power-intent registry:
|
||||||
power-intent registry:
|
|
||||||
|
|
||||||
- `messages` — every inter-agent / operator-authored message.
|
- `messages` — every inter-agent / operator-authored message.
|
||||||
`sender / recipient / body / sent_at / delivered_at / acked_at /
|
`sender / recipient / body / sent_at / delivered_at / acked_at /
|
||||||
|
|
@ -54,17 +53,6 @@ power-intent registry:
|
||||||
per-agent store — see [`/harness/` contents
|
per-agent store — see [`/harness/` contents
|
||||||
below](#state-dirs-per-agent) for where reminders (and todos)
|
below](#state-dirs-per-agent) for where reminders (and todos)
|
||||||
live.
|
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 |
|
- `agent_power` — one row per agent: `agent PK / wanted (up |
|
||||||
offline) / updated_at`, owned by `hive-c0re/src/stores/power.rs`.
|
offline) / updated_at`, owned by `hive-c0re/src/stores/power.rs`.
|
||||||
This is the durable power *intent* the job queue reconciles the
|
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
|
section](../scheduler/coordinator.md#desired-state-spec-vs-status) for who
|
||||||
writes and reads it and how reconciliation works.
|
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:
|
Retention:
|
||||||
|
|
||||||
- `Broker::vacuum_delivered` runs hourly via a tokio task in
|
- `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
|
rows are always kept — the harness `ack_turn`s only after a
|
||||||
successful turn, so `requeue_inflight` can still requeue
|
successful turn, so `requeue_inflight` can still requeue
|
||||||
an unacked row on a crash.
|
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_power` rows live until the operator destroys the agent (one row per
|
||||||
agent — nothing to vacuum).
|
agent — nothing to vacuum).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,11 @@ Settings, Core). Every page links back to H0M3, so you're
|
||||||
never more than one step from the hub.
|
never more than one step from the hub.
|
||||||
|
|
||||||
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
|
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
|
- **SW4RM** — every agent, live. This is the default tab and the one
|
||||||
you'll check most.
|
you'll check most.
|
||||||
- **P3RM1SS10NS** — what tools and system-level access each agent has.
|
- **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
|
Everything else lives on its own page instead, all reachable from the
|
||||||
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
|
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).
|
[`swarm/services.md`](../swarm/services.md#logs-victorialogs).
|
||||||
|
|
||||||
**Set up a schedule.** Scheduled prompts are swarm-level: set them with
|
**Set up a schedule.** Scheduled prompts are swarm-level: set them with
|
||||||
`swarmctl schedule` ([`swarm/schedules.md`](../swarm/schedules.md)).
|
`swarmctl schedule` ([`swarm/schedules.md`](../swarm/schedules.md)) or
|
||||||
SCH3DUL3S only lists the ones still in the hive, so you can copy them
|
on the swarm UI's schedules page ([`swarm/ui.md`](../swarm/ui.md)).
|
||||||
over.
|
|
||||||
|
|
||||||
## More depth
|
## More depth
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 `/`).
|
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
|
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`,
|
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
|
active; hash changes don't reload the page. FL0W and the other
|
||||||
standalone pages are reachable from the H0M3 hub at `/`, not
|
standalone pages are reachable from the H0M3 hub at `/`, not
|
||||||
from the dashboard tab strip.
|
from the dashboard tab strip.
|
||||||
|
|
@ -19,12 +19,12 @@ from the dashboard tab strip.
|
||||||
**Chrome header** (fixed, overlays the active tab pane):
|
**Chrome header** (fixed, overlays the active tab pane):
|
||||||
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
|
- **← 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.
|
at `/`. Every surface links back to the hub rather than to each other.
|
||||||
- **Tab strip**: `◆ SW4RM ◆`, `◆ P3RM1SS10NS ◆`,
|
- **Tab strip**: `◆ SW4RM ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only —
|
||||||
`◆ SCH3DUL3S ◆`. In-page tabs only — the SYST3M panels live on the
|
the SYST3M panels live on the
|
||||||
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
|
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
|
||||||
live on their own pages too, all reachable from the
|
live on their own pages too, all reachable from the
|
||||||
H0M3 hub (not the tab strip). Count pills on SW4RM
|
H0M3 hub (not the tab strip). Count pill on SW4RM
|
||||||
(container count) and SCH3DUL3S (active schedules left to copy).
|
(container count).
|
||||||
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
|
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
|
||||||
— sits below the tab strip.
|
— sits below the tab strip.
|
||||||
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
|
- **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
|
matrices unless the section has unsaved edits, so an in-progress edit set
|
||||||
isn't clobbered.
|
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`)
|
## ST4TS page (`/stats.html`)
|
||||||
|
|
||||||
Hive-wide turn statistics, aggregated across every agent's
|
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.
|
rows with both fields omitted rather than erroring on them.
|
||||||
Returns `200 "ok"` on
|
Returns `200 "ok"` on
|
||||||
success.
|
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
|
- `POST /api/meta-update` — `nix flake update` the selected
|
||||||
`meta/flake.lock` inputs, then rebuild the affected agents.
|
`meta/flake.lock` inputs, then rebuild the affected agents.
|
||||||
- `GET /api/dashboard/stream` — unified live event channel:
|
- `GET /api/dashboard/stream` — unified live event channel:
|
||||||
|
|
@ -892,9 +861,7 @@ payload):
|
||||||
reminder mutation: agent `remind` calls (`agent_server`),
|
reminder mutation: agent `remind` calls (`agent_server`),
|
||||||
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
|
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
|
||||||
with Reminder kind, and the scheduler tick after each delivery
|
with Reminder kind, and the scheduler tick after each delivery
|
||||||
batch (`reminder_scheduler`). The SCH3DUL3S tab's reminders section
|
batch (`reminder_scheduler`).
|
||||||
subscribes and calls `renderReminders` on receipt, so the list
|
|
||||||
updates live without polling.
|
|
||||||
- `capabilities_changed` (seq, caps: `Vec<str>`, descriptions: map,
|
- `capabilities_changed` (seq, caps: `Vec<str>`, descriptions: map,
|
||||||
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
|
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
|
||||||
capability grants. Emitted from the rebuild-queue worker after a
|
capability grants. Emitted from the rebuild-queue worker after a
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ re-render), call `refreshState()`. State shapes live in
|
||||||
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
|
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
|
||||||
`web_ui/state.rs::StateSnapshot` — when
|
`web_ui/state.rs::StateSnapshot` — when
|
||||||
adding state fields, plumb through the snapshot struct and the
|
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
|
**Focus preservation:** `refreshState` checks whether
|
||||||
`document.activeElement` sits inside one of the managed sections
|
`document.activeElement` sits inside one of the managed sections
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
|
|
||||||
/* SYST3M panel rules (rebuild queue / meta inputs / container load)
|
/* SYST3M panel rules (rebuild queue / meta inputs / container load)
|
||||||
live in system-sections.css, shared with /core.html. A few are still
|
live in system-sections.css, shared with /core.html. A few are still
|
||||||
referenced from dashboard JS (the schedules view uses `.rqe-source*`),
|
referenced from the dashboard (`.rebuild-queue`), so the dashboard
|
||||||
so the dashboard pulls them in here. */
|
pulls them in here. */
|
||||||
@import "./system-sections.css";
|
@import "./system-sections.css";
|
||||||
@import "@hive/shared/jobq-rollup.css";
|
@import "@hive/shared/jobq-rollup.css";
|
||||||
|
|
||||||
|
|
@ -709,113 +709,6 @@ footer .banner-thin {
|
||||||
cursor: not-allowed;
|
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 — sticky-bottom strip for bulk actions. */
|
||||||
.selection-bar {
|
.selection-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
|
|
@ -53,21 +53,6 @@
|
||||||
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
|
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
|
||||||
</a>
|
</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
|
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
|
||||||
the H0M3 hub — the hive-wide rollup is a standalone read-only
|
the H0M3 hub — the hive-wide rollup is a standalone read-only
|
||||||
view, not part of the operational tab strip. -->
|
view, not part of the operational tab strip. -->
|
||||||
|
|
@ -190,38 +175,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
|
||||||
(`/stats.html`), reached from the H0M3 hub. The markup + the
|
(`/stats.html`), reached from the H0M3 hub. The markup + the
|
||||||
hive-stats render JS moved there; when tabs.js boots on the
|
hive-stats render JS moved there; when tabs.js boots on the
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
<span class="home-tile-label">Dashboard</span>
|
<span class="home-tile-label">Dashboard</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="home-tile-desc"
|
<span class="home-tile-desc"
|
||||||
>containers · permissions · schedules · system</span
|
>containers · permissions · system</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// dashboard SPA: the live agent roster.
|
// dashboard SPA: the live agent roster.
|
||||||
//
|
//
|
||||||
// The roster is read by nearly every tab (the container tree, the
|
// 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
|
// container-load poll, and the selection bar), so
|
||||||
// it lives here as a single source of truth that each domain module
|
// it lives here as a single source of truth that each domain module
|
||||||
// imports rather than threading through call signatures. Everything
|
// imports rather than threading through call signatures. Everything
|
||||||
|
|
|
||||||
|
|
@ -122,34 +122,6 @@
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
font-weight: bold;
|
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) ──────────
|
/* ─── running-rebuild live log (renderRebuildLiveLog in builds.js) ──────────
|
||||||
Streams the currently-running rebuild's build log inline under the queue.
|
Streams the currently-running rebuild's build log inline under the queue.
|
||||||
Hidden (native `hidden` attr) when nothing is building. Used on
|
Hidden (native `hidden` attr) when nothing is building. Used on
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import {
|
||||||
fetchAndRenderToolGroups,
|
fetchAndRenderToolGroups,
|
||||||
initPermissions,
|
initPermissions,
|
||||||
} from "./permissions.js";
|
} from "./permissions.js";
|
||||||
import { refreshSchedules, activeScheduleCount } from "./schedules.js";
|
|
||||||
import {
|
import {
|
||||||
initJobqRollup,
|
initJobqRollup,
|
||||||
syncTransientsFromSnapshot,
|
syncTransientsFromSnapshot,
|
||||||
|
|
@ -56,7 +55,6 @@ window.marked = marked;
|
||||||
const MANAGED_SECTION_IDS = [
|
const MANAGED_SECTION_IDS = [
|
||||||
"containers-section",
|
"containers-section",
|
||||||
"inbox-section",
|
"inbox-section",
|
||||||
"schedules-section",
|
|
||||||
"capabilities-section",
|
"capabilities-section",
|
||||||
"tool-groups-section",
|
"tool-groups-section",
|
||||||
];
|
];
|
||||||
|
|
@ -149,7 +147,6 @@ window.marked = marked;
|
||||||
// mount's own effect handles the actual fetch.
|
// mount's own effect handles the actual fetch.
|
||||||
initJobqRollup();
|
initJobqRollup();
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
refreshSchedules();
|
|
||||||
restoreOpenDetails(openDetails);
|
restoreOpenDetails(openDetails);
|
||||||
// No periodic refresh timer. Phase 6 covers every container
|
// No periodic refresh timer. Phase 6 covers every container
|
||||||
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
||||||
|
|
@ -248,8 +245,8 @@ window.marked = marked;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ─── tab routing ───────────────────────────────────────────────────────
|
// ─── tab routing ───────────────────────────────────────────────────────
|
||||||
// Hash-based: `#swarm` / `#system` / `#permissions` /
|
// Hash-based: `#swarm` / `#system` / `#permissions` activate the
|
||||||
// `#schedules` activate the matching pane on the dashboard. Empty
|
// matching pane on the dashboard. Empty
|
||||||
// hash defaults to SW4RM. FL0W and ST4TS are NOT tabs —
|
// hash defaults to SW4RM. FL0W and ST4TS are NOT tabs —
|
||||||
// they're separate pages (`/flow.html`, `/stats.html`) reached from
|
// they're separate pages (`/flow.html`, `/stats.html`) reached from
|
||||||
// the H0M3 hub. Tab routing only
|
// the H0M3 hub. Tab routing only
|
||||||
|
|
@ -266,11 +263,6 @@ window.marked = marked;
|
||||||
// visibility (bar only belongs on SW4RM where agent cards live).
|
// visibility (bar only belongs on SW4RM where agent cards live).
|
||||||
document.body.dataset.activeTab = target;
|
document.body.dataset.activeTab = target;
|
||||||
renderSelectionBar(Array.from(containersState.values()));
|
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
|
// Permissions tables: SSE covers worker-applied changes
|
||||||
// (capabilities_changed / tool_groups_changed); re-fetch on
|
// (capabilities_changed / tool_groups_changed); re-fetch on
|
||||||
// activation as a safety net for any gap between SSE events and
|
// activation as a safety net for any gap between SSE events and
|
||||||
|
|
@ -310,9 +302,6 @@ window.marked = marked;
|
||||||
if (c.needs_update) swarm++;
|
if (c.needs_update) swarm++;
|
||||||
}
|
}
|
||||||
setTabCount("swarm", 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
|
// Poll the state stores on a 1s tick to keep the pill counts in
|
||||||
// sync. The state stores are mutated synchronously by every SSE
|
// sync. The state stores are mutated synchronously by every SSE
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Shared dashboard render + format helpers.
|
// Shared dashboard render + format helpers.
|
||||||
//
|
//
|
||||||
// Small pure utilities used across most dashboard tabs (the container
|
// 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
|
// an atomic-swap render helper and a handful of relative-time / duration
|
||||||
// formatters. They live here — a dashboard-internal module — rather than
|
// formatters. They live here — a dashboard-internal module — rather than
|
||||||
// in the cross-page `common.js`, because they're specific to the
|
// in the cross-page `common.js`, because they're specific to the
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,6 @@ const LAST_STOPPED_RUNNING_KEY: &str = "last_stopped_running";
|
||||||
|
|
||||||
pub struct Coordinator {
|
pub struct Coordinator {
|
||||||
pub broker: Arc<Broker>,
|
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` /
|
/// Full build-log capture. `lifecycle::run` /
|
||||||
/// `lifecycle::prebuild_toplevel` `start()` a row per attempt,
|
/// `lifecycle::prebuild_toplevel` `start()` a row per attempt,
|
||||||
/// pipe every stdout/stderr line into it, and `finish()` it on
|
/// pipe every stdout/stderr line into it, and `finish()` it on
|
||||||
|
|
@ -452,8 +448,6 @@ impl Coordinator {
|
||||||
agent_io_weight,
|
agent_io_weight,
|
||||||
} = env;
|
} = env;
|
||||||
let broker = Broker::open(db_path).context("open broker")?;
|
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`
|
// BuildLogs wants a directory (it picks its own `build_logs.sqlite`
|
||||||
// file under it); every other opener here takes the sibling
|
// file under it); every other opener here takes the sibling
|
||||||
// sqlite-file path itself. Derive the dir from `db_path`'s
|
// sqlite-file path itself. Derive the dir from `db_path`'s
|
||||||
|
|
@ -481,7 +475,6 @@ impl Coordinator {
|
||||||
let (shutdown_tx, _) = watch::channel(false);
|
let (shutdown_tx, _) = watch::channel(false);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
broker: Arc::new(broker),
|
broker: Arc::new(broker),
|
||||||
scheduled_prompts: Arc::new(scheduled_prompts),
|
|
||||||
build_logs,
|
build_logs,
|
||||||
hyperhive_flake,
|
hyperhive_flake,
|
||||||
hyperhive_docs_flake,
|
hyperhive_docs_flake,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ use crate::lifecycle;
|
||||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
(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 = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
|
||||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
(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_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||||
|
|
@ -57,7 +57,7 @@ mod lifecycle_ops;
|
||||||
mod meta_inputs;
|
mod meta_inputs;
|
||||||
mod misc_api;
|
mod misc_api;
|
||||||
pub(crate) mod permissions;
|
pub(crate) mod permissions;
|
||||||
mod schedules;
|
mod rebuild_queue;
|
||||||
mod state_files;
|
mod state_files;
|
||||||
mod state_snapshot;
|
mod state_snapshot;
|
||||||
mod tombstones;
|
mod tombstones;
|
||||||
|
|
@ -132,8 +132,7 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
|
||||||
.routes(routes!(permissions::post_permissions))
|
.routes(routes!(permissions::post_permissions))
|
||||||
.routes(routes!(permissions::get_stale_permissions))
|
.routes(routes!(permissions::get_stale_permissions))
|
||||||
.routes(routes!(permissions::delete_agent_permissions))
|
.routes(routes!(permissions::delete_agent_permissions))
|
||||||
.routes(routes!(schedules::api_schedules))
|
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
||||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
|
||||||
.routes(routes!(lifecycle_ops::post_destroy))
|
.routes(routes!(lifecycle_ops::post_destroy))
|
||||||
.routes(routes!(lifecycle_ops::post_kill))
|
.routes(routes!(lifecycle_ops::post_kill))
|
||||||
.routes(routes!(lifecycle_ops::post_restart))
|
.routes(routes!(lifecycle_ops::post_restart))
|
||||||
|
|
@ -389,8 +388,7 @@ mod router_build_probe {
|
||||||
.routes(routes!(permissions::post_permissions))
|
.routes(routes!(permissions::post_permissions))
|
||||||
.routes(routes!(permissions::get_stale_permissions))
|
.routes(routes!(permissions::get_stale_permissions))
|
||||||
.routes(routes!(permissions::delete_agent_permissions))
|
.routes(routes!(permissions::delete_agent_permissions))
|
||||||
.routes(routes!(schedules::api_schedules))
|
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
||||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
|
||||||
.routes(routes!(lifecycle_ops::post_destroy))
|
.routes(routes!(lifecycle_ops::post_destroy))
|
||||||
.routes(routes!(lifecycle_ops::post_kill))
|
.routes(routes!(lifecycle_ops::post_kill))
|
||||||
.routes(routes!(lifecycle_ops::post_restart))
|
.routes(routes!(lifecycle_ops::post_restart))
|
||||||
|
|
|
||||||
49
hive-c0re/src/dashboard/rebuild_queue.rs
Normal file
49
hive-c0re/src/dashboard/rebuild_queue.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -45,7 +45,7 @@ mod workers;
|
||||||
|
|
||||||
pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups, topology};
|
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 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};
|
pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets};
|
||||||
|
|
||||||
use coordinator::{Coordinator, HiveEnv, ServeConfig};
|
use coordinator::{Coordinator, HiveEnv, ServeConfig};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
//! Sqlite-backed host-side stores (broker, read-only scheduled prompts,
|
//! Sqlite-backed host-side stores (broker, build logs, power intent)
|
||||||
//! build logs, power intent) plus the shared connection open/migration
|
//! plus the shared connection open/migration helper (`db`). Each submodule is re-exported at the crate root, so
|
||||||
//! helper (`db`). Each submodule is re-exported at the crate root, so
|
|
||||||
//! `crate::broker::…` etc. keep working unchanged.
|
//! `crate::broker::…` etc. keep working unchanged.
|
||||||
|
|
||||||
pub mod broker;
|
pub mod broker;
|
||||||
pub mod build_logs;
|
pub mod build_logs;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod power;
|
pub mod power;
|
||||||
pub mod scheduled_prompts;
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@
|
||||||
//!
|
//!
|
||||||
//! Stored as the `agent_power` table in the coordinator DB
|
//! Stored as the `agent_power` table in the coordinator DB
|
||||||
//! (`/var/lib/hyperhive/db/broker.sqlite`, one tiny row per agent) —
|
//! (`/var/lib/hyperhive/db/broker.sqlite`, one tiny row per agent) —
|
||||||
//! same one-file-many-modules pattern as `broker` /
|
//! same one-file-many-modules pattern as `broker`, each with its own
|
||||||
//! `scheduled_prompts`, each with its own connection. Intent persists
|
//! connection. Intent persists across hive-c0re restarts; in-flight
|
||||||
//! across hive-c0re restarts; in-flight
|
|
||||||
//! queue work deliberately does not. Setting `wanted` is never a
|
//! queue work deliberately does not. Setting `wanted` is never a
|
||||||
//! queued node: operator/intent actions update the row synchronously
|
//! queued node: operator/intent actions update the row synchronously
|
||||||
//! at request time, then submit the DAG whose terminal `Reconcile`
|
//! at request time, then submit the DAG whose terminal `Reconcile`
|
||||||
|
|
@ -72,7 +71,7 @@ pub struct PowerStore {
|
||||||
impl PowerStore {
|
impl PowerStore {
|
||||||
/// Open (a connection to) the shared coordinator DB and ensure the
|
/// Open (a connection to) the shared coordinator DB and ensure the
|
||||||
/// `agent_power` table exists. `db_path` is the same sqlite file
|
/// `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> {
|
pub fn open(db_path: &Path) -> Result<Self> {
|
||||||
let conn = crate::db::open(db_path, "agent_power")?;
|
let conn = crate::db::open(db_path, "agent_power")?;
|
||||||
conn.execute_batch(SCHEMA)
|
conn.execute_batch(SCHEMA)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,6 @@ pub mod inbox;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod schedule;
|
|
||||||
pub mod stream_enrich;
|
pub mod stream_enrich;
|
||||||
pub mod term_msg;
|
pub mod term_msg;
|
||||||
pub mod wire_time;
|
pub mod wire_time;
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue