Something can happen because time passed, and land somewhere worth reading

Nothing in LLeMbas ever happened on its own. Every reply was downstream of
somebody pressing Send, and the one exception -- jobs.wake, waking a chat when a
background job finishes -- was downstream of a command they had run. PLAN.md
never listed scheduling as unbuilt because services/chat.py:618 had recorded it
as a decision: "a scheduler is a whole new concern for a single-worker
application". This is that concern, taken on deliberately, plus the two places
its output goes.

Reports first, because it is useful with no scheduling at all. A report is not a
Chat with one Message in it: it has no turns and no reply, it is read top to
bottom, and it must be writable with no chat behind it -- being the fallback for
a run whose own chat has gone. As a Chat it would need a sidebar row per daily
report, a title that regenerates itself, a composer to suppress and a bubble with
a rewind button around something that is not a turn. The section's character is
enforced by absence: nothing under reports/ includes the composer or renders
chat/_message.html, so there is no sse-connect anywhere and nothing on those
pages *can* start a generation. The test reads that off the OpenAPI schema, not
by walking app.routes -- this FastAPI keeps an included router wrapped rather
than flattening it, so the walk finds nothing and the assertion passes for the
wrong reason.

rule.py is pure, total, and was finished before anything called it. No session,
no wall clock, nothing that raises: validate clamps what it recognises, drops
what it does not, and answers {} for prose -- at which point the caller shows the
manual form. It had to be that way because the compile step's output is model
output that becomes a *timer*, which is the sharpest case of hard rule 6 here.
The invariant, pinned: anything validate accepts has a computable next
occurrence. A schedule that can never fire looks exactly like a working one on
every screen it appears on.

Wall-clock and elapsed time are kept apart because they mean different things.
at.times are wall-clock in the owner's zone, so 15:00 stays 15:00 across a
daylight-saving change -- that is what "every Monday at 3PM" means. every is
elapsed real time, so six hours stays six hours across a 23- or 25-hour day --
that is what a timer means. Conflating them gets one of the two wrong twice a
year. A time inside the spring-forward gap fires at the first minute that exists;
left to zoneinfo's own resolution it lands an hour away wearing a wall-clock time
that did not happen, and a daily 02:30 report vanishing once a year on a machine
nobody watches is the failure this file is arranged around.

The ticker claims and commits *before* it fires. The other order is a hot loop: a
firing that raises is retried every tick for ever against whatever it was that
failed, and the only symptom is load. Its blanket except is copied from the
terminal reaper for a sharper reason -- a ticker that dies on one bad row stops
every schedule on the instance and says nothing at all. No request fails, no
reply errors, no dot appears. The reports simply stop.

Three rules that look like bugs from outside: a firing arriving while the chat is
still answering queues rather than starting a second reply, and past max_queued
is skipped with the reason on the row; Run now does not advance next_fire_at, or
testing a schedule silently consumes the run it was testing; resuming recomputes
from now, or a schedule paused for a month fires the instant it comes back, once
per occurrence it missed. Catching up lives in the sweep and not in a startup
hook, because a suspended host and a long stall reproduce "its time passed while
nothing was running" with no restart to hang one on.

services/wake.py is the lock discipline extracted rather than copied. A finished
job and a due schedule are the same problem, and both depend on there being no
await between the running_for check and the writes; two lock dictionaries for one
invariant is how one of them drifts. jobs.wake is now a caller that supplies
wording, and _completion_text stayed exactly where it was because tool.background
quotes its opening sentence.

A scheduled run has no reader, so ask_user is withdrawn from resolve_tools rather
than merely discouraged in core.unattended -- a rule living only in a system
message is one a page the model just read can argue with, and a parked question
holds the reply for the whole approval_timeout with nobody to answer it. For the
same reason a task chat may not be an agent chat in v1: Manual, Edit and Plan all
stop to ask on RISK_EXECUTE, so the only two outcomes would be unattended
execution and a reply that stalls. That deserves its own pass.

Messages is bounded in the request and unbounded on disk. Only the latest chunk
is sent; everything else stays exactly where it was written. Nothing is folded
into text and nothing is deleted -- the visible conversation is identical either
way, so destroying the older rows would buy only disk, against being irreversible
and losing every attachment and tool call in the range, and it would contradict
the rule compaction already holds. should_compact refuses this kind for the
matching reason: two mechanisms narrowing one transcript is how a summary ends up
summarising a summary. The history route is the mirror of thread_tail and keeps
its four properties; the fifth is its own, that prepending moves the scroll
position, so app.js records scrollHeight before the swap and adds the difference
back after.

An empty Chat.kind meant "both sides of the switch" and had been read as "no
filter" since there were only two of them. The sidebar passes "" precisely when
agent chats are switched off -- so the moment a third kind existed, every task
chat and every Messages conversation appeared in somebody's ordinary chat list,
on exactly the instances whose owners would never think to look. KINDS stays the
two-sided fork, because set_sidebar_kind validates against it and a third entry
there makes the tree filterable to a side with no button to leave it; ALL_KINDS
is what a row may be. Both narrowings are pinned, because they are two
implementations of one rule and only one of them is SQL.

Per-user timezone had to exist for any of this: harness.py:179 was telling every
reader the *server's* idea of the date, which is survivable while the answer is
prose and stops being survivable the moment somebody says "every Monday at 3" and
something has to work out when that is.

Three things were caught by a test being wrong rather than by the code being
wrong. The task-chat "no composer" assertions were passing against a page
rendering its no-models-configured branch. A permission test asserted the same
thing twice because the administrator bypasses every permission. And every
Messages test passed with default_model never called, because none of them
configured a model -- so the pair it returns was being assigned straight to
model_id, and SQLite refuses a tuple in a String column. The fixtures now say why
they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-05 21:31:36 +02:00
co-authored by Claude Opus 5
parent 178742501d
commit 9ddc0a2103
63 changed files with 6773 additions and 67 deletions
+26
View File
@@ -491,3 +491,29 @@ a.tabs__tab { text-decoration: none; }
.mode-list__row { display: flex; gap: var(--sp-3); align-items: baseline; }
.mode-list__row dt { flex: 0 0 5rem; color: var(--ink); }
.mode-list__row dd { margin: 0; color: var(--ink-muted); font-size: var(--text-sm); }
/* --- The schedule setup form ------------------------------------------------ */
/*
Which "when" fieldset is showing follows the radio, with no JavaScript. `:has()`
is what makes that possible, and it is the same move the "Something else" box
on an approval card makes: a control and the thing it reveals cannot fall out
of step if the stylesheet is the only thing relating them.
Hidden with `display: none` on the container rather than `[hidden]`, which
would need `!important` here -- see the note in app.css about `.btn` being
`inline-flex`. Fields inside a hidden fieldset are still submitted, which is
correct: `_rule_from_form` reads only the keys the chosen repeat mode uses.
*/
.schedule-repeat {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--sp-4);
margin-bottom: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.schedule-repeat > legend { padding: 0 var(--sp-2); }
.schedule-repeat [data-repeat] { display: none; }
.schedule-repeat:has(input[value="every"]:checked) [data-repeat="every"] { display: block; }
.schedule-repeat:has(input[value="calendar"]:checked) [data-repeat="calendar"] { display: block; }
+15
View File
@@ -1630,3 +1630,18 @@
font-size: var(--text-xs);
}
.plan__note { color: var(--ink-faint); font-size: var(--text-xs); }
/* --- Reading backwards ------------------------------------------------------ */
/*
The row that fetches older messages when it comes into view. Given real height
so `revealed` fires reliably -- a zero-height element at the top of a scroll
container is intersected ambiguously, and a sentinel that never fires is a
history nobody can reach.
*/
.history-sentinel {
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-4) 0;
min-height: 2.5rem;
}
+30
View File
@@ -144,6 +144,36 @@
true
);
/* --- Reading backwards -------------------------------------------------
The mirror of `scrollThread`. Messages pages in older turns above the ones
on screen, and *prepending* moves everything down by the height of what
arrived -- so without this the reader is dragged up the page the instant
the sentinel fires, which reads as a browser bug rather than as a feature.
Height is recorded before the swap and the difference added back after, so
whatever was under the reader's eye stays there. `overflow-anchor` is not
reliable across an htmx swap, and `stick` deliberately is not touched:
reading history is not following a reply, and re-arming it here would jump
to the bottom the moment the next page landed. */
var heightBefore = -1;
document.body.addEventListener("htmx:beforeSwap", function (event) {
var el = event.target;
if (!el || !el.classList || !el.classList.contains("history-sentinel")) return;
var thread = document.getElementById("thread-scroll");
heightBefore = thread ? thread.scrollHeight : -1;
});
document.body.addEventListener("htmx:afterSwap", function (event) {
if (heightBefore < 0) return;
var thread = document.getElementById("thread-scroll");
if (thread) {
thread.scrollTop += thread.scrollHeight - heightBefore;
placed = thread.scrollTop;
}
heightBefore = -1;
});
/* `toggle` does not bubble, so this has to be registered in the CAPTURE phase.
Without the third argument the listener is never called and the whole thing
is silently dead in every browser -- the same shape of failure as a trigger
@@ -55,6 +55,10 @@
{{ icon("sparkle", "icon--sm") }}
<span class="nav-item__label">Agents</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'schedules' }}" href="/admin/schedules">
{{ icon("clock", "icon--sm") }}
<span class="nav-item__label">Scheduling</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'mcp' }}" href="/admin/mcp">
{{ icon("server", "icon--sm") }}
<span class="nav-item__label">MCP servers</span>
@@ -0,0 +1,117 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "schedules" %}
{% block title %}Scheduling - LLeMbas{% endblock %}
{% block heading %}Scheduling{% endblock %}
{% block admin_content %}
<p class="admin-lede">
A <strong>schedule</strong> runs a piece of work because time has passed
rather than because somebody asked just now — a daily summary, a check every
Monday, a reminder in an hour. Each one has its own chat and replies into it,
or files a report. People make their own under <strong>Scheduled</strong>;
what you decide here is whether the feature exists and what it may spend.
</p>
<div class="alert">
{{ icon("shield", "icon--sm") }}
<span>
This is the one thing here that spends model time with nobody watching. A
schedule pointed at a chat that can run commands would run them unattended,
so a scheduled task is never an agent chat — but everything else a model can
reach, it can reach on a timer. Give <strong>Schedule work</strong> under
Groups &amp; permissions to the people who should have it; it is off for
everybody by default.
</span>
</div>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Saved.</span></div>
{% endif %}
<form method="post" action="/admin/schedules" class="form-grid">
<section class="card">
<h2 class="card__title">Switch</h2>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
<span>Let scheduled work run</span>
</label>
<p class="field__hint">
Off by default. Turning this off stops everything firing and deletes
nothing — schedules keep their place and resume when it is turned back
on.
{% if total %}
There {{ "is" if total == 1 else "are" }} {{ total }}
schedule{{ "" if total == 1 else "s" }} on this instance,
{{ active }} of them not paused.
{% endif %}
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">How often it looks</h2>
<div class="field">
<label class="field__label" for="tick">Check for due work every</label>
<input class="input" type="number" id="tick" name="tick_seconds" min="5" max="300"
value="{{ values.tick_seconds }}">
<p class="field__hint">
Seconds. This is how late a run can be, not how often anything happens:
the finest a schedule can be set to is one minute, so anything under
that buys nothing. One indexed query per tick.
</p>
</div>
<div class="field">
<label class="field__label" for="min-interval">Nothing may repeat faster than</label>
<input class="input" type="number" id="min-interval" name="min_interval_seconds"
min="60" max="86400" value="{{ values.min_interval_seconds }}">
<p class="field__hint">
Seconds. A floor on how often one schedule may come round. Raise it if
people are setting things to run more often than the work takes.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">What it may spend</h2>
<div class="field">
<label class="field__label" for="per-user">Schedules per person</label>
<input class="input" type="number" id="per-user" name="max_per_user" min="1" max="200"
value="{{ values.max_per_user }}">
<p class="field__hint">
Refused at the point of creation, with the reason. Existing schedules
over a lowered limit keep running; only new ones are refused.
</p>
</div>
<div class="field">
<label class="field__label" for="concurrent">Runs at once</label>
<input class="input" type="number" id="concurrent" name="max_concurrent" min="1" max="20"
value="{{ values.max_concurrent }}">
<p class="field__hint">
Fifty schedules due at nine o'clock must not open fifty replies against
one endpoint. The rest wait their turn rather than being dropped.
</p>
</div>
<div class="field">
<label class="field__label" for="queued">Turns that may pile up in one chat</label>
<input class="input" type="number" id="queued" name="max_queued" min="1" max="50"
value="{{ values.max_queued }}">
<p class="field__hint">
A schedule that comes round faster than its chat can answer would build
a backlog for ever. Past this, a run is skipped and says so on the
schedule rather than joining the queue.
</p>
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save</button>
</div>
</form>
{% endblock %}
+15 -1
View File
@@ -352,7 +352,21 @@
hx-sync="this:drop"></div>
{% endif %}
{% include "chat/_composer.html" %}
{#
A task chat has no composer, and that is the section rather than a
restriction on it: a schedule's chat is written into by the schedule and
read by a person, so there is nothing for them to send. The controls
that *do* apply — run it now, pause it, edit it — take the row's place.
Suppressed by not including it, not by hiding it. `chat/_composer.html`
is the only thing that posts a message, so its absence is the guarantee;
a hidden one would still be a form anybody could post to.
#}
{% if chat and chat.kind == "task" %}
{% include "schedules/_strip.html" %}
{% else %}
{% include "chat/_composer.html" %}
{% endif %}
{% endif %}
</main>
@@ -0,0 +1,35 @@
{#
One page of older turns, oldest first, plus a fresh sentinel above them when
there is more.
No root element, and the sentinel replaces *itself* with this whole fragment
(`hx-swap="outerHTML"`), so each page lands exactly where the previous
sentinel was and the next one ends up at the top again.
The turns are rendered by `chat/_message.html`, the same template as
everything else in the thread. That is what makes scrolling up look identical
to scrolling down: there is no second way of drawing a message here, so there
is nothing that could drift from it.
#}
{% if more_before %}
{#
`hx-target="this"` is stated rather than inherited. This fragment is swapped
into a page whose composer form carries `hx-target="#thread"`, and htmx
resolves that by walking up the DOM -- the jobs chip has already demonstrated
once what an unstated target does to a transcript.
#}
<div class="history-sentinel"
hx-get="/api/messages/history?before={{ oldest_id }}"
hx-trigger="revealed"
hx-target="this"
hx-swap="outerHTML"
hx-sync="this:drop">
<span class="text-xs faint">Loading earlier messages…</span>
</div>
{% endif %}
{% for message in messages %}
{% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %}
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark %}
{#
Messages: one conversation per person, opened at the most recent turns.
The ordinary chat shell, with two differences. It opens on the live chunk
rather than on everything, and above that sits a sentinel that fetches the
page before it when scrolled into view. Everything else — the composer, the
bubbles, the tail poller, the streaming shell — is the same machinery, which
is the whole reason this is a `Chat` with a different `kind`.
#}
{% block title %}Messages - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<h1 class="topbar__title">{{ icon("chat", "icon--sm") }} Messages</h1>
<div class="topbar__actions">
{% if schedules %}
<details class="picker">
<summary class="btn btn--sm">
{{ icon("clock", "icon--sm") }} {{ schedules | length }} scheduled
</summary>
<div class="picker__menu">
<div class="picker__label">Posting into this conversation</div>
{% for item in schedules %}
<a class="jobs__row jobs__row--open" href="/scheduled/{{ item.row.id }}/edit">
<span>
<strong>{{ item.row.title }}</strong>
<span class="text-xs faint">{{ item.summary }}</span>
</span>
{% if not item.row.enabled %}
<span class="badge badge--warning">paused</span>
{% endif %}
</a>
{% endfor %}
</div>
</details>
{% endif %}
{% if can.get("schedule.use") %}
<a class="btn btn--sm" href="/scheduled/new">{{ icon("plus", "icon--sm") }} Schedule</a>
{% endif %}
</div>
</header>
<div class="thread-scroll" id="thread-scroll">
<div class="thread" id="thread">
{% if not messages %}
<div class="thread__intro">
{{ mark(cls="empty__mark", uid="messages") }}
<h2 class="empty__title">Nothing said yet</h2>
<p class="empty__text">
One conversation that keeps going. Anything scheduled to write here
will arrive in it.
</p>
</div>
{% endif %}
{#
The sentinel goes above the turns and replaces itself with the page
before, plus a fresh sentinel. Rendered only when there is something
earlier, so a short conversation has no loading row at the top.
#}
{% if more_before %}
<div class="history-sentinel"
hx-get="/api/messages/history?before={{ oldest_id }}"
hx-trigger="revealed"
hx-target="this"
hx-swap="outerHTML"
hx-sync="this:drop">
<span class="text-xs faint">Loading earlier messages…</span>
</div>
{% endif %}
{% for message in messages %}
{% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %}
</div>
</div>
{#
Outside `#thread` and outside the composer form, for the two reasons
`tests/test_chat_tail.py` already walks the chat page to enforce.
#}
<div hidden id="thread-tail"
hx-get="/api/chats/{{ chat.id }}/tail"
hx-trigger="every 5s"
hx-target="#thread" hx-swap="beforeend"
hx-sync="this:drop"></div>
{% include "chat/_composer.html" %}
</main>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', path='js/composer.js') }}" defer></script>
<script src="{{ url_for('static', path='js/commands.js') }}" defer></script>
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
{% endblock %}
@@ -0,0 +1,49 @@
{% from "_macros.html" import icon %}
{#
Reports, Messages and Scheduled: the three places activity lands that is not
a conversation you started.
Placed below pinned models and above the folder tree, and deliberately
OUTSIDE `#sidebar-tree`. Pinned models and "New chat" are outside it for the
same reason: they are not part of the Chat/Agent fork, so they must not vanish
or be re-rendered when the switch moves. Their unread dots ride the existing
out-of-band `/api/chats/unread` response, which is also outside the tree.
Not the footer, where Library and Connections live. Those are places you go to
configure or consult something; these receive activity while you are elsewhere
and therefore need a dot, and a dot down in the footer reads as a settings
notification rather than as news. Scheduled also grows, and the footer is for
fixed destinations.
Each entry is gated on its own permission, so a section somebody cannot use is
absent rather than present and refusing.
#}
<div class="nav-group" id="sidebar-sections">
{% if can.get("reports.use") %}
<a class="nav-item {{ 'is-active' if section == 'reports' }}" href="/reports">
{{ icon("archive", "icon--sm") }}
<span class="nav-item__label">Reports</span>
{# Toggled out of band by the unread poll, exactly as a chat row's is. #}
<span id="unread-reports" class="unread-dot"
{{ '' if unread_reports else 'hidden' }} title="New reports"></span>
</a>
{% endif %}
{#
No permission of its own: it is one conversation the reader owns, and
`chat.create` already decides whether they may say anything anywhere.
#}
<a class="nav-item {{ 'is-active' if section == 'messages' }}" href="/messages">
{{ icon("chat", "icon--sm") }}
<span class="nav-item__label">Messages</span>
<span id="unread-messages" class="unread-dot"
{{ '' if unread_messages else 'hidden' }} title="New messages"></span>
</a>
{% if can.get("schedule.use") %}
<a class="nav-item {{ 'is-active' if section == 'scheduled' }}" href="/scheduled">
{{ icon("clock", "icon--sm") }}
<span class="nav-item__label">Scheduled</span>
</a>
{% endif %}
</div>
@@ -21,6 +21,7 @@
<nav class="sidebar__scroll" aria-label="Chats">
{% include "partials/_sidebar_pinned.html" %}
{% include "partials/_sidebar_sections.html" %}
{% include "partials/_sidebar_tree.html" %}
</nav>
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% from "_macros.html" import icon %}
{#
The Reports shell.
Keeps the chat sidebar, like the library does: you come here from a
conversation to read what something filed and go straight back.
Deliberately no tab bar. The library has three stores that belong together and
are switched between; this is one thing. A row of tabs with a single tab in it
is a control that answers a question nobody asked.
There is no composer here and no element that fetches, which is the point of
the section: a report is read, never answered.
#}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<h1 class="topbar__title">{% block heading %}Reports{% endblock %}</h1>
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
</header>
{# `.page` content needs `.admin-scroll` around it or `.main`'s min-height:0
leaves it overflowing the viewport with nothing to scroll. #}
<div class="admin-scroll">
<div class="admin-page">
{% block reports_content %}{% endblock %}
</div>
</div>
</main>
</div>
{% endblock %}
@@ -0,0 +1,47 @@
{% extends "reports/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "reports" %}
{% block title %}{{ report.title }} - LLeMbas{% endblock %}
{% block heading %}{{ report.title }}{% endblock %}
{% block actions %}
<a class="btn btn--sm" href="/reports">{{ icon("chevron-left", "icon--sm") }} All reports</a>
{% endblock %}
{% block reports_content %}
<p class="text-sm faint">
Filed
<time datetime="{{ report.created_at.isoformat() }}">
{{ report.created_at.strftime("%d %B %Y at %H:%M") }}
</time>
{%- if report.source == "schedule" %} by a schedule
{%- elif report.source == "chat" %} from a chat
{%- endif %}
{%- if report.model_id %} · {{ report.model_id }}{% endif %}.
</p>
{% if report.error %}
<div class="alert alert--warning" style="margin-top: var(--sp-4)">
{{ icon("warning", "alert__icon") }}
<span><strong>This run did not finish cleanly.</strong> {{ report.error }}</span>
</div>
{% endif %}
{#
`body_html` has been through services/markdown.py, which is the one path
allowed to emit HTML here. A report is model output and hard rule 6 applies to
it exactly as it does to a reply.
#}
<article class="msg__body" style="margin-top: var(--sp-5)">
{{ body_html | safe }}
</article>
<div class="btn-row" style="margin-top: var(--sp-6)">
<form method="post" action="/api/reports/{{ report.id }}/delete"
data-confirm="Delete this report? It cannot be brought back.">
<button class="btn btn--sm btn--danger" type="submit">
{{ icon("trash", "icon--sm") }} Delete
</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,60 @@
{% extends "reports/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "reports" %}
{% block title %}Reports - LLeMbas{% endblock %}
{% block heading %}Reports{% endblock %}
{% block reports_content %}
<p class="admin-lede">
Finished work, filed to be read later. A model writes one when you ask for it
or when it finishes something worth keeping, and anything running on a
schedule leaves its result here. Nothing on this page can be replied to.
</p>
<form method="get" action="/reports" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
placeholder="Search reports…">
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button>
{% if q %}<a class="btn btn--sm" href="/reports">Clear</a>{% endif %}
</form>
{% if not reports %}
<div class="empty">
{{ icon("archive", "empty__mark") }}
<h2 class="empty__title">{{ "Nothing found" if q else "No reports yet" }}</h2>
<p class="empty__text">
{% if q %}
No report matches “{{ q }}”.
{% else %}
Ask a model to write one up when it has finished looking into something,
and it will appear here.
{% endif %}
</p>
</div>
{% else %}
<ul class="model-list">
{% for report in reports %}
<li class="model-list__item">
<div style="min-width: 0">
<a href="/reports/{{ report.id }}">
<strong>{{ report.title }}</strong>
</a>
<div class="text-xs faint">
<time datetime="{{ report.created_at.isoformat() }}">
{{ report.created_at.strftime("%d %b %Y, %H:%M") }}
</time>
{% if report.summary %} — {{ report.summary }}{% endif %}
</div>
</div>
<div class="btn-row">
{% if report.error %}<span class="badge badge--warning">failed</span>{% endif %}
{% if report.source == "schedule" %}<span class="badge">scheduled</span>{% endif %}
{% if report.unread %}<span class="badge badge--leaf">new</span>{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% include "library/_pager.html" %}
{% endif %}
{% endblock %}
@@ -0,0 +1,128 @@
{% from "_macros.html" import icon %}
{#
The manual rule form, shared by new and edit.
Every field maps to one key `services/schedule/rule.py:validate` understands,
and nothing here validates: there is one normaliser, it is total, and it is
the same one a compiled rule goes through. Two validators would be two ideas
of what a legal schedule is.
The repeat fieldsets are shown and hidden by the radio's `:has()` selector in
admin.css — no JavaScript, and nothing that can fall out of step with the
control, which is the same reasoning the "Something else" box on an approval
card follows.
#}
<div class="field">
<label class="field__label" for="schedule-title">Name</label>
<input class="input" id="schedule-title" name="title" required maxlength="200"
value="{{ form.title }}"
placeholder="Monday build check">
<p class="field__hint">What this appears as in the list.</p>
</div>
<div class="field">
<label class="field__label" for="schedule-instruction">What should happen</label>
<textarea class="input" id="schedule-instruction" name="instruction" rows="5" required
placeholder="Check whether the build is passing and summarise anything that broke since last week."
>{{ form.instruction }}</textarea>
<p class="field__hint">
Written for a model that will read it with no conversation around it, so say
the whole thing. Nobody will be there to answer a question about it.
</p>
</div>
<div class="field">
<label class="field__label" for="schedule-target">Where the result goes</label>
<select class="select" id="schedule-target" name="target">
{% for value, label in targets %}
<option value="{{ value }}" {{ 'selected' if form.target == value }}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<fieldset class="schedule-repeat">
<legend class="field__label">When</legend>
<label class="checkbox">
<input type="radio" name="repeat" value="once"
{{ 'checked' if form.repeat == 'once' }}>
<span>Once</span>
</label>
<label class="checkbox">
<input type="radio" name="repeat" value="every"
{{ 'checked' if form.repeat == 'every' }}>
<span>Every so often</span>
</label>
<label class="checkbox">
<input type="radio" name="repeat" value="calendar"
{{ 'checked' if form.repeat == 'calendar' }}>
<span>On particular days</span>
</label>
<div class="field">
<label class="field__label" for="schedule-start">Starting</label>
<div class="btn-row">
<input class="input" type="date" id="schedule-start" name="start_date">
<input class="input" type="time" name="start_time" value="09:00">
</div>
<p class="field__hint">
Leave the date empty for a repeating schedule and it begins now.
Times are {{ timezone }}.
</p>
</div>
<div class="field" data-repeat="every">
<label class="field__label" for="schedule-every">Repeat every</label>
<div class="btn-row">
<input class="input" type="number" id="schedule-every" name="every_amount"
min="1" value="{{ form.every_amount }}" style="max-width: 8rem">
<select class="select" name="every_unit">
{% for unit in ["minutes", "hours", "days", "weeks"] %}
<option value="{{ unit }}" {{ 'selected' if form.every_unit == unit }}>{{ unit }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="field" data-repeat="calendar">
<label class="field__label">On these days</label>
<div class="checkbox-row">
{% for index, name in weekday_names %}
<label class="checkbox">
<input type="checkbox" name="weekdays" value="{{ index }}"
{{ 'checked' if index in form.weekdays }}>
<span>{{ name }}</span>
</label>
{% endfor %}
</div>
<p class="field__hint">Leave all of them unticked for every day.</p>
<label class="field__label" for="schedule-times">At</label>
<input class="input" id="schedule-times" name="times"
value="{{ form.times or '09:00' }}" placeholder="09:00, 17:00">
<p class="field__hint">
One or more times, separated by commas. These are wall-clock times: 09:00
stays 09:00 when the clocks change.
</p>
<label class="field__label" for="schedule-monthdays">Only on these dates</label>
<input class="input" id="schedule-monthdays" name="month_days"
value="{{ form.month_days }}" placeholder="1, 15">
<p class="field__hint">Days of the month, if you want it narrower. Optional.</p>
</div>
</fieldset>
<div class="field">
<label class="field__label" for="schedule-count">Stop after</label>
<input class="input" type="number" id="schedule-count" name="count" min="0"
value="{{ form.count }}" style="max-width: 10rem">
<p class="field__hint">Number of runs. Leave it at 0 to keep going until you stop it.</p>
</div>
<div class="field">
<label class="field__label" for="schedule-until">Or stop on</label>
<input class="input" type="date" id="schedule-until" name="until">
<p class="field__hint">Optional. Nothing runs after this date.</p>
</div>
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% from "_macros.html" import icon %}
{#
The Scheduled shell: the list and the setup forms.
A schedule's own *chat* is not rendered here — it is the ordinary chat page
with its composer replaced, which is the whole reason a task chat is a `Chat`.
#}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<h1 class="topbar__title">{% block heading %}Scheduled{% endblock %}</h1>
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
</header>
<div class="admin-scroll">
<div class="admin-page">
{% if error %}
<div class="alert alert--warning">
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
</div>
{% endif %}
{% block schedules_content %}{% endblock %}
</div>
</div>
</main>
</div>
{% endblock %}
@@ -0,0 +1,68 @@
{% from "_macros.html" import icon %}
{#
What sits where a task chat's composer would be.
Three plain forms rather than htmx: each one changes what the page says about
itself — when it next runs, whether it is paused, a whole new reply — so a
redirect back is the honest response, and a refresh cannot re-submit it.
Every button here is the reader acting on their own schedule, so nothing is
checked against the chat's mode. That is the same argument the terminal panel,
the directory browser and the jobs panel already make: the model is governed,
the person is not.
#}
<div class="composer">
<div class="composer__inner">
{% if schedule %}
<div class="jobs__row jobs__row--open" style="justify-content: space-between">
<div style="min-width: 0">
<strong>{{ schedule_summary }}</strong>
<div class="text-xs faint">
{%- if not schedule.enabled %}
Paused.
{%- elif schedule_next %}
Next run {{ schedule_next.strftime("%a %-d %b, %H:%M") }}.
{%- else %}
Nothing left to run.
{%- endif %}
{%- if schedule.fired_count %} Run {{ schedule.fired_count }} times so far.{% endif %}
{%- if schedule.target == "report" %} Files a report each time.{% endif %}
</div>
{% if schedule.last_error %}
<div class="text-xs faint">{{ schedule.last_error }}</div>
{% endif %}
</div>
<div class="btn-row">
<form method="post" action="/api/schedules/{{ schedule.id }}/run">
<button class="btn btn--sm" type="submit" title="Run it now without using up the next scheduled run">
{{ icon("bolt", "icon--sm") }} Run now
</button>
</form>
<form method="post" action="/api/schedules/{{ schedule.id }}/toggle">
<input type="hidden" name="enabled" value="{{ '0' if schedule.enabled else '1' }}">
<button class="btn btn--sm" type="submit">
{% if schedule.enabled %}
{{ icon("stop-circle", "icon--sm") }} Pause
{% else %}
{{ icon("refresh", "icon--sm") }} Resume
{% endif %}
</button>
</form>
<a class="btn btn--sm" href="/scheduled/{{ schedule.id }}/edit">
{{ icon("gear", "icon--sm") }} Edit
</a>
</div>
</div>
{% else %}
{#
The schedule was removed but its chat was kept, which is the default when
somebody deletes one. Said plainly rather than left as an empty bar.
#}
<p class="nav-empty">
This chat belonged to a schedule that has been removed. It is kept as a
record of what was done.
</p>
{% endif %}
</div>
</div>
@@ -0,0 +1,37 @@
{% extends "schedules/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "scheduled" %}
{% block title %}{{ schedule.title }} - LLeMbas{% endblock %}
{% block heading %}{{ schedule.title }}{% endblock %}
{% block actions %}
<a class="btn btn--sm" href="/chat/{{ schedule.chat_id }}">Open its chat</a>
{% endblock %}
{% block schedules_content %}
<form method="post" action="/api/schedules/{{ schedule.id }}" class="card">
{% include "schedules/_form.html" %}
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save</button>
<a class="btn" href="/scheduled">Cancel</a>
</div>
</form>
<div class="card">
<h2 class="card__title">Remove</h2>
<p class="card__lede">
Stops it running. Its chat is kept by default and becomes an ordinary one,
so the transcript of everything it has already done stays where it is.
</p>
<form method="post" action="/api/schedules/{{ schedule.id }}/delete"
data-confirm="Remove this schedule? It will stop running.">
<label class="checkbox">
<input type="checkbox" name="keep_chat" value="1" checked>
<span>Keep its chat</span>
</label>
<button class="btn btn--danger btn--sm" type="submit">
{{ icon("trash", "icon--sm") }} Remove schedule
</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,54 @@
{% extends "schedules/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "scheduled" %}
{% block title %}Scheduled - LLeMbas{% endblock %}
{% block heading %}Scheduled{% endblock %}
{% block actions %}
<a class="btn btn--primary btn--sm" href="/scheduled/new">
{{ icon("plus", "icon--sm") }} New scheduled task
</a>
{% endblock %}
{% block schedules_content %}
<p class="admin-lede">
Work that runs on its own, whether or not you are here. Each one has its own
chat, and replies into it every time it comes round.
</p>
{% if not schedules %}
<div class="empty">
{{ icon("clock", "empty__mark") }}
<h2 class="empty__title">Nothing scheduled</h2>
<p class="empty__text">
Set something to run later — a daily summary, a check every Monday
morning, a reminder in an hour.
</p>
</div>
{% else %}
<ul class="model-list">
{% for item in schedules %}
<li class="model-list__item">
<div style="min-width: 0">
<a href="/chat/{{ item.row.chat_id }}"><strong>{{ item.row.title }}</strong></a>
<div class="text-xs faint">
{{ item.summary }}
{%- if item.next and item.row.enabled %}
· next {{ item.next.strftime("%a %-d %b, %H:%M") }}
{%- endif %}
{%- if item.row.fired_count %} · run {{ item.row.fired_count }} times{% endif %}
</div>
{% if item.row.last_error %}
<div class="text-xs faint">{{ item.row.last_error }}</div>
{% endif %}
</div>
<div class="btn-row">
{% if item.row.target == "report" %}<span class="badge">files a report</span>{% endif %}
{% if not item.row.enabled %}<span class="badge badge--warning">paused</span>{% endif %}
<a class="btn btn--sm" href="/scheduled/{{ item.row.id }}/edit">Edit</a>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
@@ -0,0 +1,78 @@
{% extends "schedules/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "scheduled" %}
{% block title %}New scheduled task - LLeMbas{% endblock %}
{% block heading %}What do you want to schedule?{% endblock %}
{% block actions %}
<a class="btn btn--sm" href="/scheduled">Cancel</a>
{% endblock %}
{% block schedules_content %}
{#
One question first, and the detail worked out from the answer. The manual
fields are on the same page rather than behind a second screen: somebody who
already knows exactly when it should run should not have to describe it in
prose and hope, and the fields are also where a compile that did not work out
lands — so they cannot be a fallback that only appears when something breaks.
#}
<form method="post" action="/api/schedules/describe" class="card">
<div class="field">
<label class="field__label" for="describe">Say it in your own words</label>
<textarea class="input" id="describe" name="request" rows="3" required
placeholder="Every Monday morning, check whether the build is passing and write me a report."
>{{ described }}</textarea>
<p class="field__hint">
Times are read as {{ timezone }}. Say how often, and what should happen.
</p>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">
{{ icon("sparkle", "icon--sm") }} Work it out
</button>
</div>
</form>
{% if compiled %}
{% if compiled.reason %}
<div class="alert alert--warning">
{{ icon("warning", "alert__icon") }} <span>{{ compiled.reason }}</span>
</div>
{% elif summary %}
<div class="alert alert--success">
{{ icon("check", "alert__icon") }}
<span>
This will run <strong>{{ summary }}</strong>. Check it below and change
anything that is not what you meant.
</span>
</div>
{% endif %}
{% endif %}
<form method="post" action="/api/schedules" class="card">
{#
Pre-filled from the compile when there was one. `schedule` is None here, so
the shared fields fall back to their empty state — which is why the two
compiled text values are written in explicitly rather than left to it.
#}
{% include "schedules/_form.html" %}
{% if models %}
<div class="field">
<label class="field__label" for="schedule-model">Model</label>
<select class="select" id="schedule-model" name="model_id">
<option value="">Whatever is default at the time</option>
{% for model in models %}
<option value="{{ model.model_id }}">{{ model.display_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="btn-row">
<button class="btn btn--primary" type="submit">
{{ icon("clock", "icon--sm") }} Schedule it
</button>
</div>
</form>
{% endblock %}
+31
View File
@@ -177,6 +177,37 @@
</div>
</div>
<div class="card">
<h2 class="card__title">Timezone</h2>
<p class="card__lede">
What time a model is told it is, and the zone anything you
schedule runs in. Leave it unset to follow the server.
</p>
{#
A plain form posting to its own route, not the composer's
pattern: this page has no htmx target to swap into and a
redirect back is what stops a refresh re-submitting it.
#}
<form method="post" action="/api/preferences/timezone" class="btn-row">
<select class="select" name="timezone" style="flex: 1">
<option value="" {{ 'selected' if not timezone }}>
Follow the server ({{ server_timezone }})
</option>
{% for zone in timezones %}
<option value="{{ zone }}" {{ 'selected' if zone == timezone }}>{{ zone }}</option>
{% endfor %}
</select>
<button class="btn" type="submit">Save</button>
</form>
{% if saved == "timezone" %}
<p class="field__hint">Saved. It is {{ local_now }} where you are.</p>
{% elif error == "timezone" %}
<p class="field__hint">That is not a timezone this server knows about.</p>
{% else %}
<p class="field__hint">It is currently {{ local_now }} where you are.</p>
{% endif %}
</div>
<div class="card">
<h2 class="card__title">Install as an app</h2>
<p class="card__lede">