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:
@@ -0,0 +1,373 @@
|
||||
"""The Scheduled section: making one, changing it, and the strip on its chat.
|
||||
|
||||
The failures worth pinning here are the ones that look like working software:
|
||||
a control wired to a method its route does not serve, a form that quietly
|
||||
creates something which can never fire, and a task chat that still has a way to
|
||||
send a message into it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import KIND_TASK, TARGET_REPORT, Chat, Schedule, User
|
||||
from lembas.security import permissions
|
||||
from lembas.services import schedules as schedules_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.schedule import clock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scheduling_allowed(db, registered):
|
||||
"""`schedule.use` is off by default, deliberately. Granted here so the tests
|
||||
are about the feature rather than about the gate — which has its own test.
|
||||
|
||||
A model is configured too, and that is not scaffolding: with none, the chat
|
||||
page renders its "no models yet" branch instead of the conversation, and
|
||||
every assertion about what the composer area does or does not contain passes
|
||||
for the wrong reason. This file caught exactly that.
|
||||
"""
|
||||
from lembas.db.models import Connection, Model
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SCHEDULES)
|
||||
settings_store.update(
|
||||
db, {"default_permissions": {"schedule.use": True, "reports.use": True}}
|
||||
)
|
||||
connection = Connection(
|
||||
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="test-model"))
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
def _user(db) -> User:
|
||||
return db.scalars(select(User).order_by(User.created_at)).first()
|
||||
|
||||
|
||||
def _make(client: TestClient, **overrides) -> None:
|
||||
data = {
|
||||
"title": "Monday build check",
|
||||
"instruction": "Check the build and say what broke.",
|
||||
"target": "chat",
|
||||
"repeat": "calendar",
|
||||
"weekdays": "0",
|
||||
"times": "15:00",
|
||||
"count": "0",
|
||||
**overrides,
|
||||
}
|
||||
return client.post("/api/schedules", data=data, follow_redirects=False)
|
||||
|
||||
|
||||
# --- Creating -------------------------------------------------------------------
|
||||
def test_creating_a_schedule_makes_its_chat_too(client: TestClient, db, registered):
|
||||
"""The one place "chats are created lazily" is bent, and on purpose: the
|
||||
first firing may be days away with nobody present to make one."""
|
||||
response = _make(client)
|
||||
assert response.status_code == 303
|
||||
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
chat = db.get(Chat, schedule.chat_id)
|
||||
assert chat is not None
|
||||
assert chat.kind == KIND_TASK
|
||||
assert response.headers["location"] == f"/chat/{chat.id}"
|
||||
|
||||
|
||||
def test_a_new_schedule_has_a_next_run(client: TestClient, db, registered):
|
||||
"""A schedule that can never fire looks exactly like a working one on the
|
||||
list page. This is the invariant that stops one being written at all."""
|
||||
_make(client)
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
|
||||
assert schedule.next_fire_at is not None
|
||||
assert clock.as_utc(schedule.next_fire_at) > datetime.now(tz=UTC)
|
||||
assert schedule.enabled is True
|
||||
|
||||
|
||||
def test_a_rule_that_means_nothing_is_refused_with_a_reason(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
"""Not a 400 nobody can act on: back to the form, with the reason. A silent
|
||||
refusal here would be a "Schedule it" button that appears to do nothing."""
|
||||
response = _make(client, repeat="once", start_date="", start_time="")
|
||||
|
||||
assert response.status_code == 303
|
||||
assert "/scheduled/new?error=" in response.headers["location"]
|
||||
assert db.scalars(select(Schedule)).all() == []
|
||||
|
||||
|
||||
def test_a_one_shot_in_the_past_is_refused(client: TestClient, db, registered):
|
||||
response = _make(client, repeat="once", start_date="2020-01-01", start_time="09:00")
|
||||
|
||||
assert "error=" in response.headers["location"]
|
||||
assert db.scalars(select(Schedule)).all() == []
|
||||
|
||||
|
||||
def test_the_form_and_the_engine_agree_about_what_was_stored(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
"""The edit screen is derived from the *normalised* rule, so a form showing
|
||||
something other than what runs is impossible rather than merely unlikely."""
|
||||
_make(client, repeat="every", every_amount="6", every_unit="hours")
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
|
||||
page = client.get(f"/scheduled/{schedule.id}/edit").text
|
||||
assert 'value="every"\n checked' in page or 'value="every" checked' in page
|
||||
assert 'value="6"' in page
|
||||
|
||||
|
||||
def test_a_per_user_ceiling_is_enforced(client: TestClient, db, registered):
|
||||
settings_store.update(db, {"max_per_user": 1}, key=settings_store.SCHEDULES)
|
||||
_make(client)
|
||||
|
||||
response = _make(client, title="A second one")
|
||||
|
||||
assert "error=" in response.headers["location"]
|
||||
assert len(db.scalars(select(Schedule)).all()) == 1
|
||||
|
||||
|
||||
# --- The gate --------------------------------------------------------------------
|
||||
def test_scheduling_is_off_unless_granted(client: TestClient, db, registered):
|
||||
"""`schedule.use` defaults to False: this spends model time with nobody at
|
||||
the keyboard, which is a capability chosen on purpose."""
|
||||
assert permissions.DEFAULT_PERMISSIONS["schedule.use"] is False
|
||||
|
||||
|
||||
def _as_stranger(client: TestClient, db, *, role: str = "user") -> User:
|
||||
"""Sign in as somebody who is not the administrator.
|
||||
|
||||
Necessary for any test about a permission: `permissions.resolve` gives an
|
||||
admin everything, so asking Frodo whether a gate works answers a different
|
||||
question and answers it yes.
|
||||
"""
|
||||
from lembas.security.passwords import hash_password
|
||||
|
||||
stranger = User(
|
||||
email="sam@shire.test",
|
||||
name="Sam",
|
||||
password_hash=hash_password("gardening-is-hard"),
|
||||
role=role,
|
||||
)
|
||||
db.add(stranger)
|
||||
db.commit()
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/login", data={"email": "sam@shire.test", "password": "gardening-is-hard"}
|
||||
)
|
||||
return stranger
|
||||
|
||||
|
||||
def test_the_sidebar_entry_follows_the_permission(client: TestClient, db, registered):
|
||||
_as_stranger(client, db)
|
||||
assert 'href="/scheduled"' in client.get("/chat").text
|
||||
|
||||
settings_store.update(db, {"default_permissions": {"schedule.use": False}})
|
||||
assert 'href="/scheduled"' not in client.get("/chat").text
|
||||
|
||||
|
||||
def test_a_non_admin_without_the_permission_cannot_reach_it(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
settings_store.update(db, {"default_permissions": {"schedule.use": False}})
|
||||
_as_stranger(client, db)
|
||||
|
||||
assert client.get("/scheduled", follow_redirects=False).status_code in (302, 303, 403)
|
||||
assert client.post("/api/schedules", data={}, follow_redirects=False).status_code in (
|
||||
302,
|
||||
303,
|
||||
403,
|
||||
)
|
||||
|
||||
|
||||
# --- The task chat ----------------------------------------------------------------
|
||||
def test_a_task_chat_has_no_composer(client: TestClient, db, registered):
|
||||
"""Suppressed by absence, not by hiding: `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."""
|
||||
_make(client)
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
|
||||
body = client.get(f"/chat/{schedule.chat_id}").text
|
||||
assert "composer__form" not in body
|
||||
assert 'name="content"' not in body
|
||||
# And the controls that do apply are there instead.
|
||||
assert f"/api/schedules/{schedule.id}/run" in body
|
||||
assert f"/api/schedules/{schedule.id}/toggle" in body
|
||||
|
||||
|
||||
def test_the_strip_survives_its_schedule_being_removed(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
"""Removing a schedule keeps its chat by default. The chat becomes an
|
||||
ordinary one, so it is reachable — a KIND_TASK chat with no schedule behind
|
||||
it would be in no list at all."""
|
||||
_make(client)
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
chat_id = schedule.chat_id
|
||||
|
||||
client.post(f"/api/schedules/{schedule.id}/delete", data={"keep_chat": "1"})
|
||||
|
||||
chat = db.get(Chat, chat_id)
|
||||
db.refresh(chat)
|
||||
assert chat is not None
|
||||
assert chat.kind == "chat"
|
||||
assert client.get(f"/chat/{chat_id}").status_code == 200
|
||||
|
||||
|
||||
def test_removing_a_schedule_can_take_its_chat(client: TestClient, db, registered):
|
||||
_make(client)
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
chat_id = schedule.chat_id
|
||||
|
||||
client.post(f"/api/schedules/{schedule.id}/delete", data={"keep_chat": "0"})
|
||||
|
||||
db.expunge_all()
|
||||
assert db.get(Chat, chat_id) is None
|
||||
|
||||
|
||||
# --- Controls that write -----------------------------------------------------------
|
||||
def test_pausing_and_resuming_move_the_row(client: TestClient, db, registered):
|
||||
"""Asserted on the row rather than on the response: a control wired to a
|
||||
method its route does not serve returns 405 and looks exactly like working
|
||||
software, which cost the agent-mode select an entire release."""
|
||||
_make(client)
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
|
||||
client.post(f"/api/schedules/{schedule.id}/toggle", data={"enabled": "0"})
|
||||
db.refresh(schedule)
|
||||
assert schedule.enabled is False
|
||||
|
||||
client.post(f"/api/schedules/{schedule.id}/toggle", data={"enabled": "1"})
|
||||
db.refresh(schedule)
|
||||
assert schedule.enabled is True
|
||||
|
||||
|
||||
def test_resuming_recomputes_from_now(client: TestClient, db, registered):
|
||||
"""A schedule paused for a month must not come back owing a month of runs.
|
||||
Without this it fires the instant it is switched on."""
|
||||
_make(client, repeat="every", every_amount="1", every_unit="hours")
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
|
||||
schedule.enabled = False
|
||||
schedule.next_fire_at = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
db.commit()
|
||||
|
||||
schedules_service.set_enabled(db, schedule, owner=_user(db), enabled=True)
|
||||
|
||||
assert clock.as_utc(schedule.next_fire_at) > datetime.now(tz=UTC)
|
||||
|
||||
|
||||
def test_editing_the_rule_restarts_the_count(client: TestClient, db, registered):
|
||||
"""An edited schedule is a new intention. Carrying the old `fired_count`
|
||||
into a new `count` would spend most of it before the first run."""
|
||||
_make(client)
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
schedule.fired_count = 7
|
||||
db.commit()
|
||||
|
||||
client.post(
|
||||
f"/api/schedules/{schedule.id}",
|
||||
data={
|
||||
"title": "Changed",
|
||||
"instruction": "Something else.",
|
||||
"target": "report",
|
||||
"repeat": "every",
|
||||
"every_amount": "2",
|
||||
"every_unit": "hours",
|
||||
"count": "3",
|
||||
},
|
||||
)
|
||||
|
||||
db.refresh(schedule)
|
||||
assert schedule.fired_count == 0
|
||||
assert schedule.title == "Changed"
|
||||
assert schedule.target == TARGET_REPORT
|
||||
|
||||
|
||||
def test_the_routes_refuse_the_wrong_verb(client: TestClient, db, registered):
|
||||
"""The other half of the agent-mode lesson: assert the wrong method is
|
||||
*refused*, because only that half would have failed throughout."""
|
||||
_make(client)
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
|
||||
assert client.get(f"/api/schedules/{schedule.id}/toggle").status_code == 405
|
||||
assert client.get(f"/api/schedules/{schedule.id}/run").status_code == 405
|
||||
assert client.patch(f"/api/schedules/{schedule.id}/delete").status_code == 405
|
||||
|
||||
|
||||
def test_one_persons_schedule_is_not_anothers(client: TestClient, db, registered):
|
||||
from lembas.security.passwords import hash_password
|
||||
|
||||
_make(client)
|
||||
schedule = db.scalars(select(Schedule)).one()
|
||||
|
||||
stranger = User(
|
||||
email="sam@shire.test",
|
||||
name="Sam",
|
||||
password_hash=hash_password("gardening-is-hard"),
|
||||
role="admin",
|
||||
)
|
||||
db.add(stranger)
|
||||
db.commit()
|
||||
|
||||
assert schedules_service.get(db, schedule.id, stranger) is None
|
||||
|
||||
|
||||
# --- The admin page ----------------------------------------------------------------
|
||||
def test_the_admin_page_saves_and_clamps(client: TestClient, db, registered):
|
||||
"""Clamped on read as well as here, for the reason `agents` and `images`
|
||||
give: a value stored by an earlier release, or edited into the database by
|
||||
hand, has to be survivable too. What this asserts is that neither half has
|
||||
been quietly dropped."""
|
||||
response = client.post(
|
||||
"/admin/schedules",
|
||||
data={
|
||||
"enabled": "true",
|
||||
"tick_seconds": "1",
|
||||
"max_per_user": "9999",
|
||||
"max_concurrent": "0",
|
||||
"min_interval_seconds": "1",
|
||||
"max_queued": "500",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
values = settings_store.schedules(db)
|
||||
assert values["enabled"] is True
|
||||
assert values["tick_seconds"] == 5 # floor: a busy loop otherwise
|
||||
assert values["max_per_user"] == 200 # ceiling
|
||||
# 0 falls back to the default rather than clamping to 1, because the
|
||||
# accessor reads `int(stored or default)` -- the same shape `agents` and
|
||||
# `images` use. There is no reading of "no runs at once" that anybody wants:
|
||||
# it would be a ticker that claims work and never does it.
|
||||
assert values["max_concurrent"] == 3
|
||||
assert values["min_interval_seconds"] == 60
|
||||
assert values["max_queued"] == 50
|
||||
|
||||
|
||||
def test_the_switch_actually_stops_firing(client: TestClient, db, registered):
|
||||
"""A switch that only greys something out is the failure. The sweep reads
|
||||
it, so a schedule created while it was on stops when it is turned off."""
|
||||
import anyio
|
||||
|
||||
from lembas.services.schedule import ticker
|
||||
|
||||
_make(client)
|
||||
client.post("/admin/schedules", data={"enabled": ""}, follow_redirects=False)
|
||||
|
||||
assert anyio.run(ticker.sweep) == 0
|
||||
|
||||
|
||||
def test_the_admin_page_is_admin_only(client: TestClient, db, registered):
|
||||
_as_stranger(client, db)
|
||||
assert client.get("/admin/schedules", follow_redirects=False).status_code in (
|
||||
302, 303, 403, 404,
|
||||
)
|
||||
Reference in New Issue
Block a user