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:
@@ -44,6 +44,7 @@ TOOL_CAPABILITIES = (
|
||||
("tool_custom", "Custom tools"),
|
||||
("tool_mcp", "MCP servers"),
|
||||
("tool_ask", "Ask the reader"),
|
||||
("tool_report", "Reports"),
|
||||
("tool_image", "Image generation"),
|
||||
("tool_agent", "Agent execution"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Scheduling administration: whether work may run on its own, and how much.
|
||||
|
||||
Everything here is clamped again in `settings_store.schedules` on the way out.
|
||||
That is not belt and braces for its own sake: a value stored by an earlier
|
||||
release, or edited into the database by hand, has to be survivable too, and the
|
||||
same argument `agents` and `images` already make. What this page adds is telling
|
||||
somebody *why* a number matters at the moment they change it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Schedule
|
||||
from lembas.services import settings_store
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/schedules", tags=["admin-schedules"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def schedules_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
total = int(db.scalar(select(func.count()).select_from(Schedule)) or 0)
|
||||
active = int(
|
||||
db.scalar(
|
||||
select(func.count()).select_from(Schedule).where(Schedule.enabled.is_(True))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"admin/schedules.html",
|
||||
{
|
||||
"values": settings_store.schedules(db),
|
||||
# Shown because turning the switch off does not delete anything, and
|
||||
# an administrator who has just done so should be able to see what
|
||||
# has stopped rather than infer it.
|
||||
"total": total,
|
||||
"active": active,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_schedules(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
tick_seconds: int = Form(30),
|
||||
max_per_user: int = Form(20),
|
||||
max_concurrent: int = Form(3),
|
||||
min_interval_seconds: int = Form(60),
|
||||
max_queued: int = Form(3),
|
||||
) -> Response:
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
"tick_seconds": tick_seconds,
|
||||
"max_per_user": max_per_user,
|
||||
"max_concurrent": max_concurrent,
|
||||
"min_interval_seconds": min_interval_seconds,
|
||||
"max_queued": max_queued,
|
||||
},
|
||||
key=settings_store.SCHEDULES,
|
||||
)
|
||||
log.info("scheduling %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/schedules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -19,6 +19,8 @@ from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import (
|
||||
KIND_AGENT,
|
||||
KIND_CHAT,
|
||||
KIND_MESSAGES,
|
||||
KINDS,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_USER,
|
||||
Chat,
|
||||
@@ -37,6 +39,7 @@ from lembas.services import generation as generation_service
|
||||
from lembas.services import interaction, settings_store, sse
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import steps as steps_service
|
||||
from lembas.services import tokens as tokens_service
|
||||
from lembas.services import tools as tools_service
|
||||
@@ -705,6 +708,12 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
# A temporary chat has no sidebar row, so a dot has nowhere to
|
||||
# land and the toast would name a chat nobody can navigate to.
|
||||
Chat.temporary.is_(False),
|
||||
# And neither has a conversation belonging to a section rather
|
||||
# than to the tree. Those get one dot per *section*, below --
|
||||
# forty task chats must not mean forty out-of-band spans aimed
|
||||
# at elements that are not on the page. htmx says nothing at all
|
||||
# when an OOB target is missing, so this would be silent waste.
|
||||
Chat.kind.in_(KINDS),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -721,6 +730,29 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
for c in chats
|
||||
)
|
||||
|
||||
# One dot for the whole Reports section, carried by this poll rather than by
|
||||
# a second timer of its own. Sent on every tick including empty, because it
|
||||
# has to be able to clear: a dot that survived reading the last report would
|
||||
# be news that cannot be dismissed.
|
||||
if permissions.has(db, user, "reports.use"):
|
||||
waiting = reports_service.unread_count(db, user)
|
||||
markup += (
|
||||
'<span id="unread-reports" class="unread-dot" hx-swap-oob="true"'
|
||||
f'{"" if waiting else " hidden"} title="New reports"></span>'
|
||||
)
|
||||
|
||||
# The Messages conversation, read from the row rather than created: this
|
||||
# runs every ten seconds on every open page, and `for_user` would write one
|
||||
# for every account that has never opened the section.
|
||||
conversation = db.scalars(
|
||||
select(Chat).where(Chat.user_id == user.id, Chat.kind == KIND_MESSAGES)
|
||||
).first()
|
||||
markup += (
|
||||
'<span id="unread-messages" class="unread-dot" hx-swap-oob="true"'
|
||||
f'{"" if (conversation and conversation.unread) else " hidden"}'
|
||||
' title="New messages"></span>'
|
||||
)
|
||||
|
||||
response = HTMLResponse(markup)
|
||||
if fresh:
|
||||
# HX-Trigger carries the toast; ui.js listens for it.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Messages: one conversation per person, read backwards on demand.
|
||||
|
||||
The page is the ordinary chat shell with two differences: it opens on the most
|
||||
recent turns rather than on all of them, and above them sits a sentinel that
|
||||
fetches the page before whenever it is scrolled into view.
|
||||
|
||||
That sentinel is the mirror of `GET /api/chats/{id}/tail`, which polls forwards,
|
||||
and it keeps the same four properties for the same reasons — most of all
|
||||
answering **204 to a cursor it cannot place** rather than falling back to "the
|
||||
oldest hundred", which would prepend a block the page already holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.api.pages import _chat_context, sidebar_context
|
||||
from lembas.db.models import Message, Schedule
|
||||
from lembas.services import messages as messages_service
|
||||
from lembas.services import schedules as schedules_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["messages"])
|
||||
|
||||
|
||||
def _bodies(messages: list[Message]) -> dict[str, str]:
|
||||
"""Markdown rendered server-side, keyed by id, as `chat_detail` does."""
|
||||
return {m.id: render_markdown(m.content) for m in messages if m.role == "user"}
|
||||
|
||||
|
||||
@router.get("/messages")
|
||||
async def messages_page(request: Request, db: Db, user: RequiredUser):
|
||||
conversation = messages_service.for_user(db, user)
|
||||
live = messages_service.live_messages(db, conversation)
|
||||
|
||||
# The schedules that post in here, listed beside the conversation because
|
||||
# this is where somebody would look for them -- a schedule whose output
|
||||
# arrives in this thread and whose controls are two pages away is one nobody
|
||||
# will find when they want to stop it.
|
||||
posting = list(
|
||||
db.scalars(
|
||||
schedules_service.visible(user)
|
||||
.where(Schedule.target == "messages")
|
||||
.order_by(Schedule.created_at.desc())
|
||||
)
|
||||
)
|
||||
zone = clock.zone_for(user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"messages/index.html",
|
||||
{
|
||||
"chat": conversation,
|
||||
"messages": live,
|
||||
"compacted": [],
|
||||
"bodies": _bodies(live),
|
||||
"inherited_prompt": "",
|
||||
"inherited_from": "",
|
||||
"more_before": bool(live) and messages_service.has_more_before(
|
||||
db, conversation, live[0]
|
||||
),
|
||||
"oldest_id": live[0].id if live else "",
|
||||
"schedules": [
|
||||
{
|
||||
"row": row,
|
||||
"summary": rule_service.describe(row.rule_json or {}, zone=zone),
|
||||
}
|
||||
for row in posting
|
||||
],
|
||||
**_chat_context(db, user, conversation),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/messages/history")
|
||||
async def messages_history(
|
||||
request: Request, db: Db, user: RequiredUser, before: str = ""
|
||||
) -> Response:
|
||||
"""The page of turns immediately before `before`, oldest first.
|
||||
|
||||
204 rather than a fallback whenever the cursor cannot be placed: an absent
|
||||
one, one from another chat, one belonging to a message that has gone. The
|
||||
alternative -- answering with the oldest page -- would prepend a block the
|
||||
reader is already looking at, and a duplicated transcript is something only
|
||||
a reload can reconcile.
|
||||
"""
|
||||
conversation = messages_service.for_user(db, user)
|
||||
cursor = db.get(Message, before) if before else None
|
||||
if cursor is None or cursor.chat_id != conversation.id:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
page = messages_service.older_than(db, conversation, cursor)
|
||||
if not page:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
from lembas.web.templating import templates
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"messages/_history.html",
|
||||
{
|
||||
"messages": page,
|
||||
"bodies": _bodies(page),
|
||||
"more_before": messages_service.has_more_before(db, conversation, page[0]),
|
||||
"oldest_id": page[0].id,
|
||||
# `render()` injects `user` and friends; `TemplateResponse` does
|
||||
# not, and `chat/_message.html` dereferences both `user` and `chat`
|
||||
# -- the same reason the SSE path passes them by hand. Missing
|
||||
# either is a 500 on scroll and nothing at all on the page that
|
||||
# rendered fine.
|
||||
"user": user,
|
||||
"chat": conversation,
|
||||
**_chat_context(db, user, conversation),
|
||||
},
|
||||
)
|
||||
+77
-3
@@ -2,21 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from zoneinfo import available_timezones
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import KIND_CHAT, KINDS, Chat, Folder, KnowledgeBase, Message, User
|
||||
from lembas.db.models import (
|
||||
KIND_CHAT,
|
||||
KIND_MESSAGES,
|
||||
KIND_TASK,
|
||||
KINDS,
|
||||
Chat,
|
||||
Folder,
|
||||
KnowledgeBase,
|
||||
Message,
|
||||
User,
|
||||
)
|
||||
from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import canvas as canvas_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import suggestions as suggestions_service
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
@@ -141,6 +155,7 @@ _GATE_LABELS = {
|
||||
"memory": "Memory",
|
||||
"skills": "Skills",
|
||||
"ask": "Asking you questions",
|
||||
"report": "Filing reports",
|
||||
"agent": "Running commands",
|
||||
"custom": "Custom tools",
|
||||
"mcp": "MCP servers",
|
||||
@@ -309,9 +324,13 @@ def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
Chat.folder_id.is_(None),
|
||||
Chat.archived.is_(False),
|
||||
Chat.temporary.is_(False),
|
||||
# `kind` empty means "both sides of the switch", never "no filter" --
|
||||
# see `Folder.visible_chats`. Task chats and the Messages conversation
|
||||
# have sections of their own and must never appear in this list, and
|
||||
# the case that reaches here with "" is precisely an instance with
|
||||
# agents disabled, where nobody would ever see the leak coming.
|
||||
Chat.kind.in_((kind,) if kind else KINDS),
|
||||
)
|
||||
if kind:
|
||||
narrowed = narrowed.where(Chat.kind == kind)
|
||||
unfiled = list(
|
||||
db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))
|
||||
)
|
||||
@@ -326,6 +345,22 @@ def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
# not there on any of them. The picker lists every model in the
|
||||
# administrator's order, pinned or not; pinning is not ordering.
|
||||
"pinned_models": [m for m in chat_service.available_models(db, user) if m.pinned],
|
||||
# Whether the Reports entry starts with its dot showing. Only the first
|
||||
# paint: from then on `/api/chats/unread` moves it out of band, the same
|
||||
# deal a chat row's dot has. Counted rather than existence-checked
|
||||
# because the same query answers both and a count is what a title would
|
||||
# want if this ever grows one.
|
||||
"unread_reports": reports_service.unread_count(db, user),
|
||||
# Read rather than created, for the reason the poll does the same: this
|
||||
# runs on every page, and `messages.for_user` would write a conversation
|
||||
# for every account that has never opened the section.
|
||||
"unread_messages": bool(
|
||||
db.scalar(
|
||||
select(Chat.unread).where(
|
||||
Chat.user_id == user.id, Chat.kind == KIND_MESSAGES
|
||||
)
|
||||
)
|
||||
),
|
||||
"sidebar_kind": kind,
|
||||
# Whether the switch is worth showing at all. A two-way switch with one
|
||||
# useful side is worse than no switch: it offers a view that is empty by
|
||||
@@ -571,12 +606,45 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
"bodies": bodies,
|
||||
"inherited_prompt": inherited,
|
||||
"inherited_from": inherited_from,
|
||||
**_schedule_context(db, user, chat),
|
||||
**_chat_context(db, user, chat),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _schedule_context(db: DBSession, user: User, chat: Chat) -> dict:
|
||||
"""What the strip below a task chat needs.
|
||||
|
||||
Empty for every other kind, so the three keys exist unconditionally and the
|
||||
template can ask about `schedule` without a `default(false)` -- the same
|
||||
reason `audio_service.template_flags` is passed by all four bubble
|
||||
renderers rather than by whichever one remembered.
|
||||
|
||||
`schedule` being None on a task chat is a real state, not an error: removing
|
||||
a schedule keeps its chat by default, and the strip says so.
|
||||
"""
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
if chat is None or chat.kind != KIND_TASK:
|
||||
return {"schedule": None, "schedule_summary": "", "schedule_next": None}
|
||||
|
||||
schedule = schedules_service.for_chat(db, chat)
|
||||
if schedule is None:
|
||||
return {"schedule": None, "schedule_summary": "", "schedule_next": None}
|
||||
|
||||
zone = clock.zone_for(user)
|
||||
return {
|
||||
"schedule": schedule,
|
||||
"schedule_summary": schedules_service.describe(schedule, owner=user),
|
||||
"schedule_next": (
|
||||
clock.as_utc(schedule.next_fire_at).astimezone(zone)
|
||||
if schedule.next_fire_at
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page(
|
||||
request: Request,
|
||||
@@ -606,6 +674,12 @@ async def settings_page(
|
||||
"voice_error": voice_error,
|
||||
"memories": memories_service.all_for(db, user),
|
||||
"memory_limit": memories_service.MAX_MEMORY_CHARS,
|
||||
# Sorted rather than left in set order, because a list of six
|
||||
# hundred zones that is not alphabetical is one nobody can use.
|
||||
"timezones": sorted(available_timezones()),
|
||||
"timezone": clock.name_for(user),
|
||||
"server_timezone": str(clock.server_zone()),
|
||||
"local_now": clock.now_for(user).strftime("%H:%M on %A %-d %B"),
|
||||
**context,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.config import settings
|
||||
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_all_for_user
|
||||
from lembas.services.schedule import clock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,6 +39,26 @@ async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=Tru
|
||||
return {"ok": True, "theme": theme}
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(db: Db, user: RequiredUser, timezone: str = Form("")) -> Response:
|
||||
"""Which zone this person's schedules fire in, and what time they are told it is.
|
||||
|
||||
Empty is a real answer -- "whatever the server is set to" -- rather than an
|
||||
unset field, which is why it is stored as "" instead of being removed. An
|
||||
unrecognised name is refused rather than stored and fallen back from later:
|
||||
a schedule that quietly fires in the wrong zone is the failure this whole
|
||||
field exists to prevent, and the one place to catch it is the write.
|
||||
"""
|
||||
chosen = (timezone or "").strip()
|
||||
if chosen and not clock.known(chosen):
|
||||
return RedirectResponse(
|
||||
"/settings?error=timezone", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
user.settings_json = {**(user.settings_json or {}), clock.SETTING_KEY: chosen}
|
||||
db.commit()
|
||||
return RedirectResponse("/settings?saved=timezone", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# Which CSS variables a browser is allowed to set from here, and how far. An
|
||||
# open dict would let a page store anything under somebody's account and have
|
||||
# it read back on every load; a width outside these bounds would hand them a
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Reports: a feed of finished work, and one report on its own page.
|
||||
|
||||
List-plus-detail, the same shape as the library — and for the same reason, since
|
||||
an instance running a daily schedule accumulates reports faster than anything
|
||||
else here.
|
||||
|
||||
**There is no composer on either page, and no route below accepts a message.**
|
||||
That is the whole character of the section rather than an omission: a report is
|
||||
addressed to the reader and cannot be answered, and the way to be sure of that
|
||||
is for the machinery that would answer to be absent. Nothing here renders
|
||||
`chat/_message.html`, so there is no `sse-connect` anywhere on these pages and
|
||||
nothing on them can start a generation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.library import PAGE_SIZE, _page
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import Report
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_permission("reports.use"))], tags=["reports"])
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
if q.strip():
|
||||
rows = reports_service.search(db, user, q, limit=PAGE_SIZE)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(
|
||||
db, reports_service.visible(user).order_by(Report.created_at.desc()), page
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"reports/index.html",
|
||||
{
|
||||
"section": "reports",
|
||||
"reports": rows,
|
||||
"q": q,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}")
|
||||
async def report_detail(request: Request, db: Db, user: RequiredUser, report_id: str):
|
||||
report = reports_service.get(db, report_id, user)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
|
||||
# Opening one is what reading it means. Done before rendering so the dot on
|
||||
# the way in and the dot on the way back to the list agree -- the poller
|
||||
# would otherwise re-announce a report the reader is looking at.
|
||||
reports_service.mark_read(db, report)
|
||||
return render(
|
||||
request,
|
||||
"reports/detail.html",
|
||||
{
|
||||
"section": "reports",
|
||||
"report": report,
|
||||
# Model output, through the one path allowed to emit HTML.
|
||||
"body_html": render_markdown(report.body),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/reports/{report_id}/delete")
|
||||
async def delete_report(db: Db, user: RequiredUser, report_id: str) -> Response:
|
||||
report = reports_service.get(db, report_id, user)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
|
||||
reports_service.delete(db, report)
|
||||
return RedirectResponse("/reports", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Scheduled: the list, the setup form, and one task chat's controls.
|
||||
|
||||
A schedule's own chat is rendered by the ordinary chat page — same transcript,
|
||||
same tail poller, same canvas — with the composer replaced by a strip of
|
||||
controls. That is the whole reason `KIND_TASK` reuses `Chat` and `Message`
|
||||
rather than growing tables of its own.
|
||||
|
||||
The rule form here is the **manual** one, and it is not a fallback in the
|
||||
apologetic sense: it is what makes "an empty override means off" safe for the
|
||||
compile step in Phase 3. Clearing `task.schedule_compile` must switch off the
|
||||
*compiling*, not the feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import TARGET_CHAT, TARGET_MESSAGES, TARGET_REPORT, Schedule
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import schedules as schedules_service
|
||||
from lembas.services.schedule import clock, runner
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(require_permission("schedule.use"))], tags=["schedules"]
|
||||
)
|
||||
|
||||
# What the setup form may ask for, in the order they are offered.
|
||||
OFFERED_TARGETS = (
|
||||
(TARGET_CHAT, "Its own chat"),
|
||||
(TARGET_REPORT, "Reports"),
|
||||
(TARGET_MESSAGES, "Messages"),
|
||||
)
|
||||
|
||||
REPEAT_ONCE = "once"
|
||||
REPEAT_EVERY = "every"
|
||||
REPEAT_CALENDAR = "calendar"
|
||||
|
||||
|
||||
def _rule_from_form(form) -> dict:
|
||||
"""Build a rule dict out of the setup form's fields.
|
||||
|
||||
Deliberately builds the *raw* shape and hands it to `rule.validate` rather
|
||||
than validating here: there is one normaliser, it is total, and it is the
|
||||
same one a model's compiled output will go through in Phase 3. Two
|
||||
validators would be two ideas of what a legal schedule is.
|
||||
"""
|
||||
repeat = str(form.get("repeat") or REPEAT_ONCE)
|
||||
raw: dict = {}
|
||||
|
||||
when = str(form.get("start_date") or "").strip()
|
||||
at_time = str(form.get("start_time") or "").strip() or "09:00"
|
||||
if when:
|
||||
raw["start"] = f"{when}T{at_time}:00"
|
||||
|
||||
if repeat == REPEAT_EVERY:
|
||||
unit = str(form.get("every_unit") or "hours")
|
||||
try:
|
||||
amount = int(form.get("every_amount") or 1)
|
||||
except (TypeError, ValueError):
|
||||
amount = 1
|
||||
raw["every"] = {unit: amount}
|
||||
# A timer with no start begins now. Said here rather than in the rule
|
||||
# module, which has no clock by design.
|
||||
raw.setdefault("start", datetime.now(tz=UTC).isoformat())
|
||||
|
||||
elif repeat == REPEAT_CALENDAR:
|
||||
times = [t.strip() for t in str(form.get("times") or "09:00").split(",") if t.strip()]
|
||||
raw["at"] = {
|
||||
"weekdays": [int(d) for d in form.getlist("weekdays") if str(d).isdigit()],
|
||||
"times": times,
|
||||
}
|
||||
days = str(form.get("month_days") or "").strip()
|
||||
if days:
|
||||
raw["at"]["days"] = [int(d) for d in days.split(",") if d.strip().isdigit()]
|
||||
|
||||
try:
|
||||
count = int(form.get("count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
count = 0
|
||||
if count > 0:
|
||||
raw["count"] = count
|
||||
|
||||
until = str(form.get("until") or "").strip()
|
||||
if until:
|
||||
raw["until"] = f"{until}T23:59:00"
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def _form_values(
|
||||
*, schedule: Schedule | None = None, compiled=None
|
||||
) -> dict:
|
||||
"""Everything `schedules/_form.html` renders, from whichever source there is.
|
||||
|
||||
One dict for both pages, because they are the same fields: an existing row
|
||||
on the edit page, and what the compile proposed on the new one. The form
|
||||
reads only this, so what a model suggested is displayed through exactly the
|
||||
same path as what is stored -- there is no branch in the template that could
|
||||
show one of them differently.
|
||||
"""
|
||||
if compiled is not None:
|
||||
values = _rule_defaults_from(compiled.rule)
|
||||
values.update(
|
||||
title=compiled.title, instruction=compiled.instruction, target=compiled.target
|
||||
)
|
||||
return values
|
||||
values = _rule_defaults_from((schedule.rule_json if schedule else {}) or {})
|
||||
values.update(
|
||||
title=schedule.title if schedule else "",
|
||||
instruction=schedule.instruction if schedule else "",
|
||||
target=schedule.target if schedule else TARGET_CHAT,
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def _rule_defaults_from(rule: dict) -> dict:
|
||||
"""What the form should show for a rule.
|
||||
|
||||
Derived from the *normalised* rule, so the form and the engine cannot
|
||||
disagree about what is stored -- an edit screen showing something other
|
||||
than what runs is the same failure as a label that names the wrong tool.
|
||||
Shared by the edit page and by the compile's review step, so what a model
|
||||
proposed is displayed through exactly the same path as what is saved.
|
||||
"""
|
||||
rule = rule or {}
|
||||
at = rule.get("at") or {}
|
||||
every = rule.get("every") or {}
|
||||
if at:
|
||||
repeat = REPEAT_CALENDAR
|
||||
elif every:
|
||||
repeat = REPEAT_EVERY
|
||||
else:
|
||||
repeat = REPEAT_ONCE
|
||||
minutes = int(every.get("minutes") or 0)
|
||||
unit, amount = "minutes", minutes
|
||||
for size, name in ((10080, "weeks"), (1440, "days"), (60, "hours")):
|
||||
if minutes and not minutes % size:
|
||||
unit, amount = name, minutes // size
|
||||
break
|
||||
return {
|
||||
"repeat": repeat,
|
||||
"every_unit": unit,
|
||||
"every_amount": amount or 1,
|
||||
"weekdays": at.get("weekdays") or [],
|
||||
"times": ", ".join(at.get("times") or []),
|
||||
"month_days": ", ".join(str(d) for d in at.get("days") or []),
|
||||
"count": rule.get("count") or 0,
|
||||
}
|
||||
|
||||
|
||||
def _context(db, user, schedule: Schedule | None, *, error: str = "") -> dict:
|
||||
return {
|
||||
"section": "scheduled",
|
||||
"schedule": schedule,
|
||||
"targets": OFFERED_TARGETS,
|
||||
"weekday_names": list(
|
||||
enumerate(("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"))
|
||||
),
|
||||
"form": _form_values(schedule=schedule),
|
||||
"error": error,
|
||||
"models": chat_service.available_models(db, user),
|
||||
"timezone": clock.name_for(user) or str(clock.server_zone()),
|
||||
**sidebar_context(db, user),
|
||||
}
|
||||
|
||||
|
||||
# --- The list ------------------------------------------------------------------
|
||||
@router.get("/scheduled")
|
||||
async def scheduled_list(request: Request, db: Db, user: RequiredUser):
|
||||
rows = list(
|
||||
db.scalars(schedules_service.visible(user).order_by(Schedule.created_at.desc()))
|
||||
)
|
||||
zone = clock.zone_for(user)
|
||||
return render(
|
||||
request,
|
||||
"schedules/index.html",
|
||||
{
|
||||
"section": "scheduled",
|
||||
"schedules": [
|
||||
{
|
||||
"row": row,
|
||||
"summary": rule_service.describe(row.rule_json or {}, zone=zone),
|
||||
"next": clock.as_utc(row.next_fire_at).astimezone(zone)
|
||||
if row.next_fire_at
|
||||
else None,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scheduled/new")
|
||||
async def new_schedule(request: Request, db: Db, user: RequiredUser, error: str = ""):
|
||||
"""One question: what do you want to schedule?
|
||||
|
||||
The detail comes from the compile. The manual form is on the same page
|
||||
behind a disclosure, so somebody who already knows exactly when it should
|
||||
run does not have to describe it in prose and hope.
|
||||
"""
|
||||
return render(
|
||||
request,
|
||||
"schedules/new.html",
|
||||
{**_context(db, user, None, error=error), "compiled": None, "described": ""},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/schedules/describe")
|
||||
async def describe_schedule(request: Request, db: Db, user: RequiredUser):
|
||||
"""Work a plain-language request into a schedule, and show it back.
|
||||
|
||||
Deliberately a *review* step rather than creating the schedule outright.
|
||||
The whole point of the compile is that a model chose the timing, and a
|
||||
timing nobody looked at is exactly the standing instruction this codebase
|
||||
refuses to create silently elsewhere.
|
||||
|
||||
Nothing here can fail into an error page: a cleared fragment, an endpoint
|
||||
that is down, prose instead of JSON and a rule that means nothing all end at
|
||||
the same place, which is the form with the reader's own words in it and a
|
||||
line saying what to finish.
|
||||
"""
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services.schedule import compile as compile_service
|
||||
|
||||
form = await request.form()
|
||||
described = str(form.get("request") or "").strip()
|
||||
|
||||
template = prompts_service.resolve(db, "task.schedule_compile")
|
||||
resolved = compile_service.endpoint_for(db, user)
|
||||
if resolved is None:
|
||||
compiled = compile_service.Compiled(
|
||||
instruction=described,
|
||||
title=described[:80],
|
||||
reason="There is no model configured to work this out, so fill it in yourself.",
|
||||
)
|
||||
else:
|
||||
endpoint, model_id = resolved
|
||||
compiled = await compile_service.compile_request(
|
||||
endpoint, model_id, described, template=template, user=user
|
||||
)
|
||||
|
||||
context = _context(db, user, None)
|
||||
# The compiled values become the form's values, so the reader edits what the
|
||||
# model proposed rather than being shown it beside an empty form.
|
||||
context["form"] = _form_values(compiled=compiled)
|
||||
return render(
|
||||
request,
|
||||
"schedules/new.html",
|
||||
{
|
||||
**context,
|
||||
"compiled": compiled,
|
||||
"described": described,
|
||||
"summary": rule_service.describe(compiled.rule, zone=clock.zone_for(user))
|
||||
if compiled.rule
|
||||
else "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scheduled/{schedule_id}/edit")
|
||||
async def edit_schedule(
|
||||
request: Request, db: Db, user: RequiredUser, schedule_id: str, error: str = ""
|
||||
):
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
return render(request, "schedules/edit.html", _context(db, user, schedule, error=error))
|
||||
|
||||
|
||||
# --- Writing --------------------------------------------------------------------
|
||||
@router.post("/api/schedules")
|
||||
async def create_schedule(request: Request, db: Db, user: RequiredUser) -> Response:
|
||||
form = await request.form()
|
||||
try:
|
||||
schedule = schedules_service.create(
|
||||
db,
|
||||
owner=user,
|
||||
title=str(form.get("title") or ""),
|
||||
instruction=str(form.get("instruction") or ""),
|
||||
request=str(form.get("instruction") or ""),
|
||||
rule=_rule_from_form(form),
|
||||
target=str(form.get("target") or TARGET_CHAT),
|
||||
model_id=str(form.get("model_id") or ""),
|
||||
)
|
||||
except schedules_service.ScheduleError as error:
|
||||
# Back to the form with the reason, rather than a 400 nobody can act on.
|
||||
return RedirectResponse(
|
||||
f"/scheduled/new?error={error}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
return RedirectResponse(f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/schedules/{schedule_id}")
|
||||
async def save_schedule(
|
||||
request: Request, db: Db, user: RequiredUser, schedule_id: str
|
||||
) -> Response:
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
form = await request.form()
|
||||
try:
|
||||
schedules_service.update(
|
||||
db,
|
||||
schedule,
|
||||
owner=user,
|
||||
title=str(form.get("title") or ""),
|
||||
instruction=str(form.get("instruction") or ""),
|
||||
rule=_rule_from_form(form),
|
||||
target=str(form.get("target") or TARGET_CHAT),
|
||||
)
|
||||
except schedules_service.ScheduleError as error:
|
||||
return RedirectResponse(
|
||||
f"/scheduled/{schedule_id}/edit?error={error}",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
return RedirectResponse(f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/schedules/{schedule_id}/toggle")
|
||||
async def toggle_schedule(
|
||||
db: Db, user: RequiredUser, schedule_id: str, enabled: str = Form("")
|
||||
) -> Response:
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
schedules_service.set_enabled(
|
||||
db, schedule, owner=user, enabled=enabled not in ("", "0", "false")
|
||||
)
|
||||
return RedirectResponse(
|
||||
f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/schedules/{schedule_id}/run")
|
||||
async def run_schedule(db: Db, user: RequiredUser, schedule_id: str) -> Response:
|
||||
"""Fire it now, without consuming the run it was scheduled for.
|
||||
|
||||
`runner.run_now` is a different entry point from the ticker's for exactly
|
||||
that reason -- testing a schedule must not skip the real one.
|
||||
"""
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
chat_id = schedule.chat_id
|
||||
await runner.run_now(schedule_id)
|
||||
return RedirectResponse(f"/chat/{chat_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/schedules/{schedule_id}/delete")
|
||||
async def delete_schedule(
|
||||
db: Db, user: RequiredUser, schedule_id: str, keep_chat: str = Form("1")
|
||||
) -> Response:
|
||||
schedule = schedules_service.get(db, schedule_id, user)
|
||||
if schedule is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
|
||||
schedules_service.delete(db, schedule, keep_chat=keep_chat not in ("", "0", "false"))
|
||||
return RedirectResponse("/scheduled", status_code=status.HTTP_303_SEE_OTHER)
|
||||
Reference in New Issue
Block a user