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)
|
||||
@@ -121,6 +121,7 @@ FTS_INDEXES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
||||
("documents_fts", "documents", ("title", "description", "extracted_text")),
|
||||
("notes_fts", "notes", ("title", "body")),
|
||||
("skills_fts", "skills", ("name", "description", "body")),
|
||||
("reports_fts", "reports", ("title", "summary", "body")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,8 +20,11 @@ from lembas.db.models.attachment import (
|
||||
)
|
||||
from lembas.db.models.canvas import ScratchDoc
|
||||
from lembas.db.models.chat import (
|
||||
ALL_KINDS,
|
||||
KIND_AGENT,
|
||||
KIND_CHAT,
|
||||
KIND_MESSAGES,
|
||||
KIND_TASK,
|
||||
KINDS,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_SYSTEM,
|
||||
@@ -52,6 +55,23 @@ from lembas.db.models.library import (
|
||||
SkillRevision,
|
||||
chat_knowledge_bases,
|
||||
)
|
||||
from lembas.db.models.report import (
|
||||
SOURCE_CHAT,
|
||||
SOURCE_MANUAL,
|
||||
SOURCE_SCHEDULE,
|
||||
SOURCES,
|
||||
Report,
|
||||
)
|
||||
from lembas.db.models.schedule import (
|
||||
ORIGIN_MODEL,
|
||||
ORIGIN_USER,
|
||||
ORIGINS,
|
||||
TARGET_CHAT,
|
||||
TARGET_MESSAGES,
|
||||
TARGET_REPORT,
|
||||
TARGETS,
|
||||
Schedule,
|
||||
)
|
||||
from lembas.db.models.setting import Setting
|
||||
from lembas.db.models.suggestion import Suggestion
|
||||
from lembas.db.models.tool import (
|
||||
@@ -84,12 +104,15 @@ __all__ = [
|
||||
"AUTH_METHODS",
|
||||
"AUTH_PASSWORD",
|
||||
"AUTHOR_USER",
|
||||
"ALL_KINDS",
|
||||
"Attachment",
|
||||
"KINDS",
|
||||
"KIND_AGENT",
|
||||
"KIND_CHAT",
|
||||
"KIND_DOCUMENT",
|
||||
"KIND_IMAGE",
|
||||
"KIND_MESSAGES",
|
||||
"KIND_TASK",
|
||||
"KIND_TEXT",
|
||||
"PRINCIPAL_GROUP",
|
||||
"PRINCIPAL_USER",
|
||||
@@ -111,8 +134,21 @@ __all__ = [
|
||||
"SECRET_NONE",
|
||||
"SECRET_PLACEMENTS",
|
||||
"SECRET_QUERY",
|
||||
"ORIGINS",
|
||||
"ORIGIN_MODEL",
|
||||
"ORIGIN_USER",
|
||||
"SOURCES",
|
||||
"SOURCE_CHAT",
|
||||
"SOURCE_LINK",
|
||||
"SOURCE_MANUAL",
|
||||
"SOURCE_SCHEDULE",
|
||||
"SOURCE_UPLOAD",
|
||||
"TARGETS",
|
||||
"TARGET_CHAT",
|
||||
"TARGET_MESSAGES",
|
||||
"TARGET_REPORT",
|
||||
"Report",
|
||||
"Schedule",
|
||||
"Chat",
|
||||
"Job",
|
||||
"Connection",
|
||||
|
||||
@@ -26,8 +26,28 @@ ROLE_TOOL = "tool"
|
||||
# chat is pointed at a machine before it starts and stays pointed there.
|
||||
KIND_CHAT = "chat"
|
||||
KIND_AGENT = "agent"
|
||||
|
||||
# The two sides of the sidebar's Chat/Agent switch, and nothing else.
|
||||
# `KINDS` must NOT grow: `api/preferences.py:set_sidebar_kind` validates against
|
||||
# it, so a third entry would make the tree filterable to a side with no button
|
||||
# to leave it -- the "one side of a fork nobody can move" failure the
|
||||
# `sidebar_split` guard already exists to prevent.
|
||||
KINDS = (KIND_CHAT, KIND_AGENT)
|
||||
|
||||
# Conversations that belong to a section of their own rather than to the tree.
|
||||
# A Messages conversation is one per person; a task chat belongs to a schedule
|
||||
# and is reached through Scheduled. Neither is ever listed among the chats, so
|
||||
# neither is a side of the switch.
|
||||
KIND_MESSAGES = "messages"
|
||||
KIND_TASK = "task"
|
||||
|
||||
# What a row's `kind` may actually be. Every listing that means "the sidebar
|
||||
# tree" filters on KINDS; every check that means "is this a real value" uses
|
||||
# this. Reading `kind == ""` as "no filter" is what leaks a task chat into the
|
||||
# ordinary list on an instance with agents switched off, where the sidebar
|
||||
# passes "" precisely because there is no switch to read.
|
||||
ALL_KINDS = (*KINDS, KIND_MESSAGES, KIND_TASK)
|
||||
|
||||
# Duplicated from services/agent/policy.py rather than imported: a model module
|
||||
# importing a service would invert the dependency, and this is only the column
|
||||
# default. policy.MODES is the vocabulary; this is what a row starts as.
|
||||
@@ -90,14 +110,20 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
folder branch went through the relationship and filtered nothing.
|
||||
|
||||
`kind` narrows to one side of the sidebar's Chat/Agent switch. Empty
|
||||
means both, which is what every caller outside the sidebar wants.
|
||||
means *both sides of the switch* -- which is not the same as "no filter",
|
||||
and the difference only became visible once a third kind existed. An
|
||||
instance with agents disabled passes "" because there is no switch to
|
||||
read, so a bare `not kind` would list every task chat and the Messages
|
||||
conversation among somebody's ordinary chats. Those have sections of
|
||||
their own and are never in the tree.
|
||||
|
||||
Ordered like the unfiled list: pinned first, then most recently touched.
|
||||
"""
|
||||
wanted = (kind,) if kind else KINDS
|
||||
kept = [
|
||||
chat
|
||||
for chat in self.chats
|
||||
if not chat.archived and not chat.temporary and (not kind or chat.kind == kind)
|
||||
if not chat.archived and not chat.temporary and chat.kind in wanted
|
||||
]
|
||||
kept.sort(key=lambda chat: chat.updated_at, reverse=True)
|
||||
kept.sort(key=lambda chat: not chat.pinned)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Reports: what was found, written down once and never replied to."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
|
||||
# Where a report came from. Not a foreign key to anything -- see `source_id`.
|
||||
SOURCE_SCHEDULE = "schedule"
|
||||
SOURCE_CHAT = "chat"
|
||||
SOURCE_MANUAL = "manual"
|
||||
SOURCES = (SOURCE_SCHEDULE, SOURCE_CHAT, SOURCE_MANUAL)
|
||||
|
||||
|
||||
class Report(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A finished piece of work, filed.
|
||||
|
||||
Deliberately not a `Chat` with one `Message` in it. A report is read top to
|
||||
bottom and never answered, so everything a conversation carries -- a
|
||||
composer, a sidebar row, a title that regenerates itself, a bubble with an
|
||||
avatar and a rewind button -- would be machinery to suppress rather than
|
||||
machinery to use. It is the same line `services/library/` already draws
|
||||
between a note and a chat: a durable artefact is not a turn.
|
||||
|
||||
It must also be writable with no chat behind it at all, being the fallback
|
||||
destination for a scheduled run whose own chat has gone.
|
||||
|
||||
`body` is Markdown written by a model and goes through
|
||||
`services/markdown.py` like everything else from an endpoint. Hard rule 6
|
||||
applies here exactly as it does in a transcript.
|
||||
"""
|
||||
|
||||
__tablename__ = "reports"
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
# One line for the list page, so a feed of forty reports can be read without
|
||||
# opening any of them. Written by the model beside the body; falls back to
|
||||
# the body's first line when it did not bother.
|
||||
summary: Mapped[str] = mapped_column(String(500), default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
source: Mapped[str] = mapped_column(String(16), default=SOURCE_MANUAL, nullable=False)
|
||||
# The chat or the schedule this came out of, kept so a report can say where
|
||||
# it was made. Deliberately not a ForeignKey: `migrations.py` compiles the
|
||||
# column type only, so a REFERENCES clause would exist on a fresh database
|
||||
# and not on an upgraded one -- the same reason `Chat.compacted_through_id`
|
||||
# and `Folder.ssh_profile_id` are plain ids. Both are validated on read, and
|
||||
# the row outliving what it points at is normal rather than exceptional: a
|
||||
# report is worth keeping after the chat that produced it has been deleted.
|
||||
source_id: Mapped[str] = mapped_column(String(32), default="")
|
||||
schedule_id: Mapped[str] = mapped_column(String(32), default="")
|
||||
model_id: Mapped[str] = mapped_column(String(300), default="")
|
||||
|
||||
# NOT NULL with a scalar default so `migrations._add_column_sql` can backfill
|
||||
# it if this column is ever added to a table that already has rows.
|
||||
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# Why a run produced nothing worth reading. A scheduled report that failed
|
||||
# is still a report -- one that silently did not appear is indistinguishable
|
||||
# from a schedule that never fired.
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Report {self.title!r}>"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Schedules: what should happen later, and where its result goes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
# Where a firing's result is delivered. Chosen per schedule rather than fixed by
|
||||
# the screen it was made on: Reports has to stay reachable from anywhere, being
|
||||
# the fallback, and a schedule somebody wants moved from its own chat to Reports
|
||||
# should not have to be built again.
|
||||
TARGET_CHAT = "chat"
|
||||
TARGET_REPORT = "report"
|
||||
TARGET_MESSAGES = "messages"
|
||||
TARGETS = (TARGET_CHAT, TARGET_REPORT, TARGET_MESSAGES)
|
||||
|
||||
# Who made it. Kept because "why is this running?" is a question with two very
|
||||
# different answers, and one of them is "a model decided to".
|
||||
ORIGIN_USER = "user"
|
||||
ORIGIN_MODEL = "model"
|
||||
ORIGINS = (ORIGIN_USER, ORIGIN_MODEL)
|
||||
|
||||
|
||||
class Schedule(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One standing instruction and when it comes due.
|
||||
|
||||
The row carries no recurrence logic at all: `rule_json` is read by
|
||||
`services/schedule/rule.py`, which is pure and knows nothing about rows.
|
||||
What lives here is the bookkeeping the ticker needs to claim a firing
|
||||
without doing it twice.
|
||||
"""
|
||||
|
||||
__tablename__ = "schedules"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
||||
|
||||
# What the reader actually typed, kept verbatim and for ever. The compile
|
||||
# rewrites it into `instruction`, and "what did I actually ask for" has to
|
||||
# survive that -- both so the edit form can show it and so a recompile has
|
||||
# something to work from other than its own previous output.
|
||||
request: Mapped[str] = mapped_column(Text, default="")
|
||||
# What is sent when it fires. The compiled form: standalone, since it is
|
||||
# read with no conversation around it.
|
||||
instruction: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
rule_json: Mapped[dict] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
target: Mapped[str] = mapped_column(String(16), default=TARGET_CHAT, nullable=False)
|
||||
# The chat this fires into. Deliberately not a ForeignKey -- `migrations.py`
|
||||
# compiles the column type only, so a REFERENCES clause would exist on a
|
||||
# fresh database and not on an upgraded one. Validated on read, and a
|
||||
# dangling value disables the schedule rather than raising every tick.
|
||||
chat_id: Mapped[str] = mapped_column(String(32), default="")
|
||||
model_id: Mapped[str] = mapped_column(String(300), default="")
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# The ticker's entire query. Nullable because "nothing more to do" is a real
|
||||
# state -- a spent count, a closed window, a calendar matching nothing --
|
||||
# and is different from "due at the epoch".
|
||||
next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
last_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Stamped when a firing starts and cleared when it finishes, so a run that
|
||||
# died halfway says so instead of looking like one that never happened.
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
fired_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
# Why the last run did not work. Shown on the schedule's own page: a
|
||||
# schedule that silently stopped producing anything is indistinguishable
|
||||
# from one that was never due.
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
origin: Mapped[str] = mapped_column(String(16), default=ORIGIN_USER, nullable=False)
|
||||
compiled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Schedule {self.title!r} {'on' if self.enabled else 'off'}>"
|
||||
@@ -19,6 +19,7 @@ from lembas.api import (
|
||||
admin_images,
|
||||
admin_models,
|
||||
admin_prompts,
|
||||
admin_schedules,
|
||||
admin_search,
|
||||
admin_suggestions,
|
||||
admin_tools,
|
||||
@@ -31,8 +32,11 @@ from lembas.api import (
|
||||
files,
|
||||
folders,
|
||||
library,
|
||||
messages,
|
||||
pages,
|
||||
preferences,
|
||||
reports,
|
||||
schedules,
|
||||
terminal,
|
||||
)
|
||||
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
|
||||
@@ -98,6 +102,28 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception: # noqa: BLE001 - a job that cannot be rehydrated is not fatal
|
||||
log.exception("could not rehydrate background jobs")
|
||||
|
||||
# Schedules. `release_claims` first, because a firing interrupted by the
|
||||
# last shutdown left a claim stamp that would otherwise read as permanently
|
||||
# running. Then the ticker, started here rather than lazily like the
|
||||
# terminal reaper: a schedule can be due at startup with nobody logged in,
|
||||
# which is most of the point of having one. Inside the loop, so its tasks
|
||||
# land in this event loop.
|
||||
#
|
||||
# Catching up on what was missed is deliberately NOT done here. It lives in
|
||||
# the sweep, because a suspended laptop, a paused container and a long stall
|
||||
# all reproduce "its time passed while nothing was running" with no restart
|
||||
# for a startup hook to hang on.
|
||||
try:
|
||||
from lembas.services.schedule.ticker import release_claims
|
||||
from lembas.services.schedule.ticker import start as start_ticker
|
||||
|
||||
released = release_claims()
|
||||
if released:
|
||||
log.info("released %s interrupted schedule claim(s)", released)
|
||||
start_ticker()
|
||||
except Exception: # noqa: BLE001 - scheduling failing must not block startup
|
||||
log.exception("could not start the schedule ticker")
|
||||
|
||||
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
|
||||
log.info("data directory: %s", settings.data_dir.resolve())
|
||||
yield
|
||||
@@ -107,7 +133,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
from lembas.services.agent.jobs import shutdown as stop_jobs
|
||||
from lembas.services.agent.terminal import shutdown as stop_terminals
|
||||
from lembas.services.generation import shutdown as stop_generations
|
||||
from lembas.services.schedule.ticker import shutdown as stop_ticker
|
||||
|
||||
# Before the generations, so nothing new is fired into a chat whose reply is
|
||||
# about to be cancelled and persisted.
|
||||
await stop_ticker()
|
||||
await stop_generations()
|
||||
# Open shells have nothing to persist: whatever was running on the far side
|
||||
# is cut off mid-command. Every deploy does this, and the panel is told why
|
||||
@@ -142,12 +172,16 @@ def create_app() -> FastAPI:
|
||||
app.include_router(files.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_models.router)
|
||||
app.include_router(admin_audio.router)
|
||||
app.include_router(admin_search.router)
|
||||
app.include_router(admin_schedules.router)
|
||||
app.include_router(admin_images.router)
|
||||
app.include_router(admin_prompts.router)
|
||||
app.include_router(admin_suggestions.router)
|
||||
|
||||
@@ -163,6 +163,33 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"schedule.use",
|
||||
"Schedule work",
|
||||
"Set things to run later, on their own — once, or on a repeating "
|
||||
"timetable. This spends model time with nobody at the keyboard, so it "
|
||||
"is a capability chosen on purpose rather than one everybody has.",
|
||||
False,
|
||||
"Scheduling",
|
||||
),
|
||||
PermissionDef(
|
||||
"reports.use",
|
||||
"Keep reports",
|
||||
"Read the Reports section: finished pieces of work filed for them to "
|
||||
"read later, by a model that was asked for one or by something that ran "
|
||||
"while they were away.",
|
||||
True,
|
||||
"Reports",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.report",
|
||||
"File reports",
|
||||
"Let a model write a report when it finishes a piece of work, and read "
|
||||
"back ones it filed earlier. A report is addressed to the reader and "
|
||||
"cannot be replied to, so this costs nothing but a place to put things.",
|
||||
True,
|
||||
"Reports",
|
||||
),
|
||||
PermissionDef(
|
||||
"audio.transcribe",
|
||||
"Dictate messages",
|
||||
|
||||
@@ -73,9 +73,6 @@ LAUNCH_GRACE = 10.0
|
||||
_JOBS: dict[str, JobState] = {}
|
||||
# One watcher task per job being polled to completion.
|
||||
_WATCHERS: dict[str, asyncio.Task] = {}
|
||||
# One lock per chat, so two jobs finishing at once cannot each start a reply --
|
||||
# see `wake`.
|
||||
_WAKE_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
|
||||
# Stop watching a job after this. The remote process may keep running; we simply
|
||||
# stop holding a watcher for it and mark it lost. A job that runs longer than
|
||||
@@ -615,13 +612,6 @@ async def _watch(
|
||||
|
||||
|
||||
# --- Waking the model ----------------------------------------------------------
|
||||
def _lock(chat_id: str) -> asyncio.Lock:
|
||||
lock = _WAKE_LOCKS.get(chat_id)
|
||||
if lock is None:
|
||||
lock = _WAKE_LOCKS[chat_id] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
def _completion_text(
|
||||
job_id: str, command: str, status: str, exit_status: int | None, output: str
|
||||
) -> str:
|
||||
@@ -651,48 +641,18 @@ async def wake(
|
||||
`queued` for that reply's `_inject`/`_drain` to deliver; if the chat is idle,
|
||||
a fresh reply is started to answer it, the `send_queued_now` move.
|
||||
|
||||
The whole thing is under a per-chat lock, and there is no `await` between the
|
||||
running-check and starting the reply, so two jobs finishing at once cannot
|
||||
each spin up a generation -- the second sees the first's reply already live
|
||||
and leaves its completion for it. That is the invariant the queue exists to
|
||||
hold, reached here from outside a request.
|
||||
|
||||
The completion is a user-role turn whose *content* names itself a machine
|
||||
event -- `_inject` sends a queued turn verbatim, so the framing cannot live
|
||||
there; it lives in the words, the way `execute_plan` quotes the plan.
|
||||
The lock discipline that makes that safe lives in `services/wake.py`, which
|
||||
is the one copy of it -- schedules need the identical rule, and two lock
|
||||
dictionaries for one invariant is how one of them drifts. What stays here is
|
||||
the *wording*, because `tool.background` quotes `_completion_text`'s opening
|
||||
sentence to the model and rewording it would break that instruction with
|
||||
nothing anywhere to notice.
|
||||
"""
|
||||
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services import wake as wake_service
|
||||
|
||||
content = _completion_text(job_id, command, status, exit_status, output)
|
||||
async with _lock(chat_id):
|
||||
running = generation_service.running_for(chat_id) is not None
|
||||
assistant_id = ""
|
||||
try:
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None:
|
||||
return
|
||||
# `machine` changes the bubble and nothing else: the role stays
|
||||
# `user` because `_inject` sends a queued turn verbatim and the
|
||||
# request must carry a user turn, and the framing the *model*
|
||||
# reads is in the words `_completion_text` wrote. What it stops
|
||||
# is the transcript claiming the reader typed this.
|
||||
chat_service.create_message(
|
||||
db, chat, ROLE_USER, content, queued=running, machine=True
|
||||
)
|
||||
if not running:
|
||||
assistant = chat_service.create_message(
|
||||
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
||||
)
|
||||
assistant_id = assistant.id
|
||||
except Exception: # noqa: BLE001 - a failed wake must not crash the watcher
|
||||
log.exception("could not wake chat %s for job %s", chat_id, job_id)
|
||||
return
|
||||
if assistant_id:
|
||||
generation_service.ensure(chat_id, assistant_id)
|
||||
await wake_service.wake_chat(
|
||||
chat_id, _completion_text(job_id, command, status, exit_status, output)
|
||||
)
|
||||
|
||||
|
||||
# --- Rehydration and shutdown --------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
KIND_MESSAGES,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_SYSTEM,
|
||||
ROLE_USER,
|
||||
@@ -265,6 +266,21 @@ def build_messages(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
).all()
|
||||
|
||||
# The Messages conversation never ends, so it cannot all be sent. Only the
|
||||
# most recent turns go; everything before them stays on screen and out of
|
||||
# the request. One branch, and the bound is applied before the loop rather
|
||||
# than inside it so the filters below still see a contiguous tail.
|
||||
#
|
||||
# Not compaction: that summarises with a model call and a threshold, on a
|
||||
# conversation somebody decided to shorten. This is mechanical, lossless and
|
||||
# permanent, which is why `compaction.should_compact` refuses this kind --
|
||||
# two mechanisms fighting over one transcript is how you get a summary of a
|
||||
# summary.
|
||||
if chat.kind == KIND_MESSAGES:
|
||||
from lembas.services import messages as messages_service
|
||||
|
||||
history = history[-messages_service.LIVE_CHUNK :]
|
||||
|
||||
for message in history:
|
||||
if upto is not None and message.id == upto.id:
|
||||
break
|
||||
|
||||
@@ -25,7 +25,7 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import ROLE_ASSISTANT, Chat, Message
|
||||
from lembas.db.models import KIND_MESSAGES, ROLE_ASSISTANT, Chat, Message
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import settings_store, tokens
|
||||
|
||||
@@ -187,6 +187,14 @@ def should_compact(db: DBSession, chat: Chat, *, pending: str = "") -> bool:
|
||||
if limit <= 0:
|
||||
return False
|
||||
|
||||
# The Messages conversation bounds its own request mechanically, in
|
||||
# `build_messages`. Two mechanisms narrowing one transcript is how a summary
|
||||
# ends up summarising a summary -- and this one would be summarising turns
|
||||
# that are already outside the request, which achieves nothing at the cost
|
||||
# of a model call and a divider on a page that has no divider.
|
||||
if chat.kind == KIND_MESSAGES:
|
||||
return False
|
||||
|
||||
last = last_complete(db, chat)
|
||||
if last is None:
|
||||
return False
|
||||
|
||||
@@ -33,16 +33,16 @@ clearing those fragments in the admin page restores it exactly.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import User
|
||||
from lembas.db.models import KIND_TASK, User
|
||||
from lembas.services import prompts, settings_store
|
||||
from lembas.services.library import memories as memories_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
from lembas.services.schedule import clock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -176,11 +176,22 @@ def context_variables(
|
||||
|
||||
offered = tools or []
|
||||
families = _families(db, offered)
|
||||
stamp = datetime.now().astimezone()
|
||||
# The reader's zone, not the server's. Telling somebody in another country
|
||||
# that it is Tuesday when it is Wednesday where they are was survivable
|
||||
# while the answer was only ever prose; it stops being survivable the moment
|
||||
# they can say "every Monday at 3" and something has to work out when that
|
||||
# is. `zone_for` falls back to the server's, so an instance where nobody has
|
||||
# set one behaves exactly as it always did.
|
||||
stamp = clock.now_for(user)
|
||||
|
||||
values: dict[str, str] = {
|
||||
"today": stamp.strftime("%A %-d %B %Y"),
|
||||
"now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
|
||||
# Named so a model working out a schedule can say which zone it meant,
|
||||
# and so `core.today` can carry it without a second fragment. Empty when
|
||||
# nobody has chosen one, which drops the line rather than printing the
|
||||
# server's zone as though it were a decision.
|
||||
"timezone": clock.name_for(user),
|
||||
"instance_name": str(settings_store.get(db, "instance_name") or "LLeMbas"),
|
||||
"user_name": (user.name or "") if user is not None else "",
|
||||
"model_name": "",
|
||||
@@ -230,6 +241,12 @@ def context_variables(
|
||||
"agent_instructions": "",
|
||||
"agent_instructions_file": "",
|
||||
"plan": "",
|
||||
# Empty everywhere but a scheduled task's own chat, which is what makes
|
||||
# it the gate on `core.unattended` as well as the content of
|
||||
# `context.schedule`. Two fragments, one variable, and no way for the
|
||||
# warning to appear without the thing it warns about.
|
||||
"schedule_instruction": "",
|
||||
"schedule_summary": "",
|
||||
}
|
||||
|
||||
if chat is not None:
|
||||
@@ -251,9 +268,37 @@ def context_variables(
|
||||
if "agent" in families:
|
||||
values.update(_agent_values(db, chat, user))
|
||||
|
||||
# Not gated on a family: a scheduled task has no tools of its own, and
|
||||
# the thing that must reach the model is precisely that nobody is
|
||||
# reading. One primary-key lookup, the same deal `plan` gets.
|
||||
if chat.kind == KIND_TASK:
|
||||
values.update(_schedule_values(db, chat, user))
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def _schedule_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||
"""What a scheduled task's chat is for, and how often it comes round.
|
||||
|
||||
A task chat accumulates every run, so by the tenth the original instruction
|
||||
is far out of sight up the transcript. Put back in front of the model each
|
||||
turn rather than left to be inferred -- exactly what `Chat.plan_message_id`
|
||||
exists to do for a plan.
|
||||
"""
|
||||
from lembas.services import schedules as schedules_service
|
||||
|
||||
schedule = schedules_service.for_chat(db, chat)
|
||||
if schedule is None:
|
||||
# The schedule was removed and its chat kept. There is nothing standing
|
||||
# to say, so the fragments vanish rather than describing a timer that no
|
||||
# longer exists.
|
||||
return {}
|
||||
return {
|
||||
"schedule_instruction": schedule.instruction or schedule.request or "",
|
||||
"schedule_summary": schedules_service.describe(schedule, owner=user),
|
||||
}
|
||||
|
||||
|
||||
def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||
"""What an agent chat's harness needs to say about where it is."""
|
||||
from lembas.services import plans as plans_service
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Messages: one long-running conversation per person.
|
||||
|
||||
Signal-shaped rather than chat-shaped. There is exactly one of these per
|
||||
account, it is never titled, never filed and never deleted, and it is meant to
|
||||
run for years — which is the whole difficulty, because a conversation that never
|
||||
ends cannot all be sent to a model.
|
||||
|
||||
**What is stored and what is used are different things, and only the second is
|
||||
bounded.** Every turn is kept, for ever, and scrolling up shows all of them
|
||||
exactly as they were written. What reaches the model is the most recent
|
||||
`LIVE_CHUNK` turns and nothing before them.
|
||||
|
||||
**Nothing is folded into text and nothing is deleted**, and that is a
|
||||
deliberate reading of "compressed and history only". The visible conversation
|
||||
would be identical either way, so the only thing destroying the older turns
|
||||
would buy is disk — against which it is irreversible, it loses every attachment
|
||||
and tool call in the folded range, and it contradicts the rule this codebase
|
||||
already holds for compaction: *hiding turns is not deleting them*. Bounding the
|
||||
request achieves the whole of what the feature needs. If the rows ever do need
|
||||
folding, it is one function against this same boundary and the pages above it do
|
||||
not change.
|
||||
|
||||
The consequence is worth stating plainly rather than discovering: **a Messages
|
||||
conversation is infinite on screen and finite in the request.** Past the live
|
||||
chunk the model genuinely does not see what was said, and it is told so.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import KIND_MESSAGES, Chat, Message, User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How many turns reach the model. The "latest chunk", and deliberately larger
|
||||
# than a page of history: it is the part that has to be enough to hold a
|
||||
# conversation in, while the rest only has to be readable.
|
||||
LIVE_CHUNK = 40
|
||||
|
||||
# How many older turns one scroll-up fetches. Bigger than the live chunk because
|
||||
# reading back is cheap -- no tokens, no request, just rows.
|
||||
HISTORY_PAGE = 100
|
||||
|
||||
|
||||
def for_user(db: DBSession, user: User) -> Chat:
|
||||
"""This person's Messages conversation, made if it is not there yet.
|
||||
|
||||
The second deliberate exception to "chats are created lazily", and for a
|
||||
different reason than a task chat's: a schedule can post in here before
|
||||
anybody has ever opened the page, and `wake_chat` needs a row to write to.
|
||||
Get-or-create rather than a startup sweep, so an account that never opens
|
||||
Messages never grows one.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
existing = db.scalars(
|
||||
select(Chat)
|
||||
.where(Chat.user_id == user.id, Chat.kind == KIND_MESSAGES)
|
||||
.order_by(Chat.created_at)
|
||||
).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# `default_model` answers with the *pair* -- the model id and the connection
|
||||
# it was reached through -- because a chat stores both and resolving the
|
||||
# second later would pick whichever connection happens to offer the id.
|
||||
# Unpacked rather than assigned, which is the mistake this comment exists to
|
||||
# stop being made again: assigning the tuple straight to `model_id` writes a
|
||||
# tuple into a String column and SQLite refuses the insert.
|
||||
chosen = chat_service.default_model(db, user)
|
||||
model_id, connection_id = chosen if chosen else ("", None)
|
||||
|
||||
conversation = Chat(
|
||||
user_id=user.id,
|
||||
kind=KIND_MESSAGES,
|
||||
title="Messages",
|
||||
# Titling never runs on this one: there is no first exchange to name and
|
||||
# the name is fixed. Set so nothing downstream has to special-case it.
|
||||
title_generated=True,
|
||||
model_id=model_id,
|
||||
connection_id=connection_id,
|
||||
)
|
||||
db.add(conversation)
|
||||
db.commit()
|
||||
return conversation
|
||||
|
||||
|
||||
def count(db: DBSession, chat: Chat) -> int:
|
||||
return int(
|
||||
db.scalar(select(func.count()).select_from(Message).where(Message.chat_id == chat.id))
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def live_messages(db: DBSession, chat: Chat, *, limit: int = LIVE_CHUNK) -> list[Message]:
|
||||
"""The most recent turns, oldest first.
|
||||
|
||||
Fetched newest-first and reversed rather than offset from the start: an
|
||||
offset would have to be recomputed from a count on every request, and would
|
||||
be wrong the moment a turn arrived between the two queries.
|
||||
"""
|
||||
newest = db.scalars(
|
||||
select(Message)
|
||||
.where(Message.chat_id == chat.id)
|
||||
.order_by(Message.created_at.desc(), Message.id.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return list(reversed(newest))
|
||||
|
||||
|
||||
def older_than(
|
||||
db: DBSession, chat: Chat, cursor: Message, *, limit: int = HISTORY_PAGE
|
||||
) -> list[Message]:
|
||||
"""The page of turns immediately before `cursor`, oldest first.
|
||||
|
||||
The comparison is done in SQL with an `id` tie-breaker, exactly as
|
||||
`thread_tail` does going the other way. That is not decoration: under a bare
|
||||
`<`, a row sharing the cursor's microsecond can never be reached, and a
|
||||
message that cannot be scrolled back to is a message that is gone.
|
||||
"""
|
||||
rows = db.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.chat_id == chat.id,
|
||||
(Message.created_at < cursor.created_at)
|
||||
| ((Message.created_at == cursor.created_at) & (Message.id < cursor.id)),
|
||||
)
|
||||
.order_by(Message.created_at.desc(), Message.id.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return list(reversed(rows))
|
||||
|
||||
|
||||
def has_more_before(db: DBSession, chat: Chat, cursor: Message) -> bool:
|
||||
"""Whether the sentinel should be rendered again above a page.
|
||||
|
||||
Asked separately rather than by fetching one extra row, because the answer
|
||||
is needed *after* the page has been reversed and the extra row would have to
|
||||
be trimmed off the wrong end.
|
||||
"""
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(
|
||||
Message.chat_id == chat.id,
|
||||
(Message.created_at < cursor.created_at)
|
||||
| ((Message.created_at == cursor.created_at) & (Message.id < cursor.id)),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) > 0
|
||||
@@ -124,6 +124,24 @@ class Variable:
|
||||
VARIABLES: tuple[Variable, ...] = (
|
||||
Variable("today", "Today's date", "The current date, written out in full."),
|
||||
Variable("now", "Date and time", "The current date and time, with the offset from UTC."),
|
||||
Variable(
|
||||
"schedule_instruction",
|
||||
"Scheduled instruction",
|
||||
"In a scheduled task's chat: what it is to do each time it runs. Empty "
|
||||
"everywhere else, which is what makes it the gate on the unattended "
|
||||
"guidance as well as its content.",
|
||||
),
|
||||
Variable(
|
||||
"schedule_summary",
|
||||
"Schedule",
|
||||
"In a scheduled task's chat: how often it runs, in words.",
|
||||
),
|
||||
Variable(
|
||||
"timezone",
|
||||
"Timezone",
|
||||
"The reader's timezone, as an IANA name. Empty when they have not chosen "
|
||||
"one, in which case the times above are the server's.",
|
||||
),
|
||||
Variable("instance_name", "Instance name", "What this installation is called."),
|
||||
Variable("user_name", "User's name", "The name of the person in the conversation."),
|
||||
Variable("model_name", "Model", "The display name of the model answering."),
|
||||
@@ -248,6 +266,18 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
),
|
||||
Variable("question", "Question", "The first message. Chat title task only."),
|
||||
Variable("answer", "Answer", "The first reply. Chat title task only."),
|
||||
Variable(
|
||||
"request",
|
||||
"The request",
|
||||
"What somebody said they wanted to happen, in their own words. "
|
||||
"Working out a schedule only.",
|
||||
),
|
||||
Variable(
|
||||
"targets",
|
||||
"Destinations",
|
||||
"Where a scheduled run's result may be sent, as a list of the values "
|
||||
"that are accepted. Working out a schedule only.",
|
||||
),
|
||||
Variable(
|
||||
"transcript",
|
||||
"Transcript",
|
||||
@@ -573,13 +603,16 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
label="Today's date",
|
||||
group=GROUP_CORE,
|
||||
order=20,
|
||||
variables=("today",),
|
||||
variables=("today", "timezone"),
|
||||
hint="A model has no clock. Without this it cannot tell whether what it "
|
||||
"recalls is current, and will not think to check.",
|
||||
"recalls is current, and will not think to check. The timezone line "
|
||||
"carries its own variable, so it disappears on an instance where nobody "
|
||||
"has chosen one rather than announcing the server's as a decision.",
|
||||
default=(
|
||||
"Today is {{today}}. Your training data stops well before this, so treat "
|
||||
"anything time-sensitive as something to check rather than something you "
|
||||
"already know."
|
||||
"already know.\n"
|
||||
"- Times the person gives you are in {{timezone}} unless they say otherwise."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1117,6 +1150,72 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
),
|
||||
),
|
||||
# --- Context -------------------------------------------------------------
|
||||
Fragment(
|
||||
key="core.unattended",
|
||||
label="Nobody is watching",
|
||||
group=GROUP_CORE,
|
||||
order=35,
|
||||
requires=("schedule_instruction",),
|
||||
hint="Only in a scheduled task's chat. The point a model cannot work "
|
||||
"out for itself is that there is no reader — so the usual moves of "
|
||||
"asking what was meant, or stopping to check, end the run having done "
|
||||
"nothing. This is the prompt half; the enforcement is that `ask_user` "
|
||||
"is not offered here at all, because a rule living only in a system "
|
||||
"message is one a page the model just read can argue with.",
|
||||
default=(
|
||||
"- This chat runs on a schedule and nobody is necessarily reading it. You "
|
||||
"cannot ask a question and wait for an answer: there is no one to answer, "
|
||||
"and the run would simply end. Where something is ambiguous, choose the "
|
||||
"most reasonable reading, do the work, and say plainly in your reply what "
|
||||
"you assumed and what you would want confirmed. Finish what you were asked "
|
||||
"to do in this one reply."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="context.schedule",
|
||||
label="What this task is for",
|
||||
group=GROUP_CONTEXT,
|
||||
order=310,
|
||||
variables=("schedule_instruction", "schedule_summary"),
|
||||
requires=("schedule_instruction",),
|
||||
hint="A task chat accumulates every run, so by the tenth the original "
|
||||
"instruction is far out of sight. This puts it back in front of the "
|
||||
"model each turn, the same way the current plan is — one lookup, and no "
|
||||
"guessing from the transcript.",
|
||||
default=(
|
||||
"## This scheduled task\n"
|
||||
"It runs: {{schedule_summary}}\n"
|
||||
"Each time, you are to: {{schedule_instruction}}\n"
|
||||
"Earlier runs are above. Say what has changed since the last one rather "
|
||||
"than repeating it, unless there is nothing above to compare with."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.report",
|
||||
label="Reports",
|
||||
group=GROUP_TOOLS,
|
||||
order=246,
|
||||
families=("report",),
|
||||
hint="Appears when the report tools are offered. The whole of what a "
|
||||
"model cannot infer from the schema is the audience: a report is read "
|
||||
"somewhere else, later, by somebody who cannot answer it. Everything "
|
||||
"else here follows from that — write it whole, do not end on a "
|
||||
"question, and do not file one for a two-line answer that has already "
|
||||
"been given in the conversation.",
|
||||
default=(
|
||||
"- You can file a report with report_write: a finished piece of work, kept "
|
||||
"where the person will find it later. Write one when you are asked for one, "
|
||||
"and when you finish something long enough that its result is worth keeping — "
|
||||
"an investigation, an account of what you changed, a summary of what you "
|
||||
"found. Do not file one for an answer you have just given in two lines; the "
|
||||
"conversation already holds that. A report is read on its own, away from this "
|
||||
"chat and possibly long afterwards, and the person cannot reply to it — so "
|
||||
"say what you were asked, what you found and what you conclude, refer to "
|
||||
"nothing above, and end on a finding rather than a question. report_search "
|
||||
"and report_get read back ones filed earlier, which is worth doing before a "
|
||||
"recurring report so this one can say what changed."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="context.knowledge_scope",
|
||||
label="Which knowledge bases",
|
||||
@@ -1518,6 +1617,57 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"from there."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.schedule_compile",
|
||||
label="Working out a schedule",
|
||||
group=GROUP_TASKS,
|
||||
order=440,
|
||||
variables=("request", "now", "timezone", "targets"),
|
||||
hint="One request, made once, when somebody describes something they "
|
||||
"want to happen later. It turns their words into a recurrence and into "
|
||||
"an instruction that reads sensibly with no conversation around it — "
|
||||
"which is how it will be read, days later, by a model that was not "
|
||||
"there when it was typed. Clearing this switches off the *working out*, "
|
||||
"not scheduling: the setup screen then asks for the time in its own "
|
||||
"fields, with the reader's words already filled in. The reply is parsed "
|
||||
"leniently and anything unusable falls back to that same form, so a "
|
||||
"model that answers in prose costs a moment rather than a broken "
|
||||
"schedule.",
|
||||
default=(
|
||||
"Turn the request below into a schedule. Reply with one JSON object and "
|
||||
"nothing else — no commentary, no code fence.\n"
|
||||
"\n"
|
||||
"It is currently {{now}} ({{timezone}}). Times you write are in that zone.\n"
|
||||
"\n"
|
||||
"The object has these keys:\n"
|
||||
'- "title": a short name for this, five words or fewer.\n'
|
||||
'- "instruction": what should be done each time it runs, written out in '
|
||||
"full. It will be read on its own, with none of this conversation around "
|
||||
"it and nobody available to answer a question about it, so say everything "
|
||||
"it needs. Write it as an instruction, not as a description.\n"
|
||||
'- "target": where the result goes — one of: {{targets}}. Use "report" '
|
||||
"when the point is something to read later, and \"chat\" otherwise.\n"
|
||||
'- "schedule": an object saying when, with these optional keys:\n'
|
||||
' "start": an ISO timestamp for the first (or only) run.\n'
|
||||
' "every": one of {"minutes": n}, {"hours": n}, {"days": n}, '
|
||||
'{"weeks": n} — a plain timer.\n'
|
||||
' "at": {"weekdays": [0-6, Monday is 0], "days": [1-31], '
|
||||
'"months": [1-12], "times": ["HH:MM"]} — a calendar. Leave a list out '
|
||||
"to mean every one of them.\n"
|
||||
' "count": how many times in total, if they said a number.\n'
|
||||
' "until": an ISO timestamp to stop after, if they gave one.\n'
|
||||
"\n"
|
||||
'Use "every" for "in ten minutes" or "every six hours". Use "at" for '
|
||||
'"every Monday at 3" or "daily at nine". Use both only for something '
|
||||
'like "every other Tuesday". For a one-off, give "start" alone.\n'
|
||||
"\n"
|
||||
"If they did not say when, guess the most ordinary reading rather than "
|
||||
"leaving it out — daily at 09:00 for something described as daily.\n"
|
||||
"\n"
|
||||
"The request:\n"
|
||||
"{{request}}"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
register_source(_builtin_source)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Reports: filing a finished piece of work, and finding it again.
|
||||
|
||||
A report is written and read; it is never answered. That is the whole shape of
|
||||
the thing, and it is why this store is deliberately thinner than
|
||||
`services/library/`: there is no sharing, because a report is a record of what
|
||||
somebody's own model did on their behalf, and no revisions, because a report
|
||||
describes a moment rather than a document being worked on.
|
||||
|
||||
`sharing.visible_to` is therefore absent on purpose rather than forgotten. If
|
||||
reports ever become shareable, `RESOURCE_TYPES` is where that starts, and every
|
||||
listing here has to go through the helper -- six independently written
|
||||
permission checks is how one of them ends up written slightly differently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import SOURCE_MANUAL, SOURCES, Report, User
|
||||
from lembas.services.library.fts import search_ids
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INDEX = "reports_fts"
|
||||
|
||||
MAX_TITLE_CHARS = 300
|
||||
MAX_SUMMARY_CHARS = 500
|
||||
MAX_BODY_CHARS = 60_000
|
||||
SNIPPET_CHARS = 400
|
||||
|
||||
|
||||
def visible(user: User | None):
|
||||
"""Every report this person owns.
|
||||
|
||||
Takes no session because it builds a query rather than running one, and
|
||||
takes `None` to mean nobody so an unauthenticated caller gets an empty
|
||||
result instead of an exception -- the same shape `sharing.visible_to` has,
|
||||
so a later move to shared reports is a change of one line here.
|
||||
"""
|
||||
if user is None:
|
||||
return select(Report).where(Report.id.is_(None))
|
||||
return select(Report).where(Report.owner_id == user.id)
|
||||
|
||||
|
||||
def get(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or user is None or report.owner_id != user.id:
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Report]:
|
||||
return list(db.scalars(visible(user).order_by(Report.created_at.desc()).limit(limit)))
|
||||
|
||||
|
||||
def search(db: DBSession, user: User | None, needle: str, *, limit: int = 20) -> list[Report]:
|
||||
"""Reports matching `needle`, best match first.
|
||||
|
||||
Ids come back from FTS and the rows are re-ordered by hit position, exactly
|
||||
as the library stores do -- the index knows about ranking and the ORM query
|
||||
knows about ownership, and neither is asked to do the other's job.
|
||||
"""
|
||||
hits = search_ids(db, INDEX, needle, limit=limit * 4)
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(db.scalars(visible(user).where(Report.id.in_(list(order)))))
|
||||
rows.sort(key=lambda report: order.get(report.id, len(order)))
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def unread_count(db: DBSession, user: User | None) -> int:
|
||||
if user is None:
|
||||
return 0
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count()).select_from(Report).where(
|
||||
Report.owner_id == user.id, Report.unread.is_(True)
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _first_line(body: str) -> str:
|
||||
"""A summary for a model that did not write one.
|
||||
|
||||
Markdown headings are stripped rather than shown: a list of reports all
|
||||
beginning "# " reads as a bug, and the heading is nearly always the title
|
||||
again.
|
||||
"""
|
||||
for line in (body or "").splitlines():
|
||||
stripped = line.strip().lstrip("#").strip()
|
||||
if stripped:
|
||||
return stripped[:MAX_SUMMARY_CHARS]
|
||||
return ""
|
||||
|
||||
|
||||
def create(
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
title: str,
|
||||
body: str,
|
||||
summary: str = "",
|
||||
source: str = SOURCE_MANUAL,
|
||||
source_id: str = "",
|
||||
schedule_id: str = "",
|
||||
model_id: str = "",
|
||||
error: str = "",
|
||||
unread: bool = True,
|
||||
) -> Report:
|
||||
"""File a report.
|
||||
|
||||
Trimming happens here rather than at the column so an over-long write from
|
||||
a tool is filed short with everything else intact, instead of failing the
|
||||
turn -- the rule `memories` already follows.
|
||||
|
||||
`unread` defaults to True because every caller that matters is something
|
||||
that happened without the reader present. A report somebody typed themselves
|
||||
passes False.
|
||||
"""
|
||||
report = Report(
|
||||
owner_id=owner.id,
|
||||
title=(title.strip() or "Untitled report")[:MAX_TITLE_CHARS],
|
||||
summary=(summary.strip() or _first_line(body))[:MAX_SUMMARY_CHARS],
|
||||
body=(body or "").strip()[:MAX_BODY_CHARS],
|
||||
source=source if source in SOURCES else SOURCE_MANUAL,
|
||||
source_id=source_id or "",
|
||||
schedule_id=schedule_id or "",
|
||||
model_id=model_id or "",
|
||||
error=error or "",
|
||||
unread=unread,
|
||||
)
|
||||
db.add(report)
|
||||
db.commit()
|
||||
return report
|
||||
|
||||
|
||||
def mark_read(db: DBSession, report: Report) -> Report:
|
||||
if report.unread:
|
||||
report.unread = False
|
||||
db.commit()
|
||||
return report
|
||||
|
||||
|
||||
def delete(db: DBSession, report: Report) -> None:
|
||||
db.delete(report)
|
||||
db.commit()
|
||||
|
||||
|
||||
def snippet(report: Report) -> str:
|
||||
text = (report.summary or report.body or "").strip()
|
||||
if len(text) <= SNIPPET_CHARS:
|
||||
return text
|
||||
return text[:SNIPPET_CHARS].rstrip() + "…"
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Scheduling: what should happen later, and what makes it happen.
|
||||
|
||||
Four modules, split by what each of them is allowed to touch:
|
||||
|
||||
- `clock.py` -- whose idea of "now" is in force. No session, no rows.
|
||||
- `rule.py` -- the recurrence spec, and when it next comes due. Pure and
|
||||
total: it never raises, never opens a session and never reads
|
||||
the wall clock, which is what lets it be tested exhaustively
|
||||
before anything calls it.
|
||||
- `ticker.py` -- the loop that notices a schedule is due, and claims it.
|
||||
- `runner.py` -- what actually happens when one fires.
|
||||
|
||||
The order matters and is the phasing: everything upstream of `ticker.py` can be
|
||||
got wrong quietly, so it is settled first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Whose idea of "now" is in force.
|
||||
|
||||
Until schedules existed, nothing here needed a timezone: `harness.py` stamped
|
||||
`datetime.now().astimezone()` and every reader was told the *server's* idea of
|
||||
the date. That is harmless when the answer is prose and wrong the moment a
|
||||
person says "every Monday at 3" and something has to work out when that is.
|
||||
|
||||
One resolver, because the model compiling a schedule, the screen echoing it back
|
||||
and the ticker firing it must agree about what Monday means. A disagreement here
|
||||
does not raise -- it fires at the wrong time, which is the kind of wrong nobody
|
||||
can debug from the outside.
|
||||
|
||||
Deliberately no new column. The zone lives in `user.settings_json["timezone"]`
|
||||
beside the theme, empty meaning "whatever the server is set to" -- which is the
|
||||
honest default for the single-user instance this mostly runs on, and is a real
|
||||
answer rather than a prompt to go and choose one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, tzinfo
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
|
||||
|
||||
from lembas.db.models import User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SETTING_KEY = "timezone"
|
||||
|
||||
|
||||
def server_zone() -> tzinfo:
|
||||
"""What the machine is set to, as a real tzinfo.
|
||||
|
||||
`astimezone()` on a naive stamp attaches the system zone, which is what the
|
||||
harness has always used. Read once per call rather than cached: a host whose
|
||||
zone changes under a long-running process is rare, and a cache that gets it
|
||||
wrong is worse than the lookup.
|
||||
"""
|
||||
return datetime.now().astimezone().tzinfo or UTC
|
||||
|
||||
|
||||
def known(name: str) -> bool:
|
||||
"""Whether this is a zone name Python can actually resolve.
|
||||
|
||||
`available_timezones()` reads the system database and is not cheap, so it is
|
||||
only consulted for a value that is about to be stored. Everything on the
|
||||
read path goes through `zone_for`, which simply falls back.
|
||||
"""
|
||||
return bool(name) and name in available_timezones()
|
||||
|
||||
|
||||
def resolve(name: str) -> tzinfo:
|
||||
"""A zone by name, falling back to the server's rather than raising.
|
||||
|
||||
A stored name can stop resolving -- the tz database is a system package and
|
||||
a zone can be renamed out from under a row. Falling back means a schedule
|
||||
fires an hour out at worst; raising means it does not fire at all and the
|
||||
ticker logs an exception nobody reads.
|
||||
"""
|
||||
if not name:
|
||||
return server_zone()
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except (ZoneInfoNotFoundError, ValueError, OSError):
|
||||
log.warning("unknown timezone %r, falling back to the server's", name)
|
||||
return server_zone()
|
||||
|
||||
|
||||
def name_for(user: User | None) -> str:
|
||||
"""The stored name, or "" meaning the server's. Never resolved here --
|
||||
the settings form wants the raw value so an unset zone shows as unset."""
|
||||
if user is None:
|
||||
return ""
|
||||
return str((user.settings_json or {}).get(SETTING_KEY) or "")
|
||||
|
||||
|
||||
def zone_for(user: User | None) -> tzinfo:
|
||||
"""The zone a schedule of this person's fires in, and the one the harness
|
||||
should tell them the time in."""
|
||||
return resolve(name_for(user))
|
||||
|
||||
|
||||
def now_for(user: User | None) -> datetime:
|
||||
"""Aware, in the reader's zone."""
|
||||
return datetime.now(tz=zone_for(user))
|
||||
|
||||
|
||||
def to_utc(moment: datetime, *, zone: tzinfo) -> datetime:
|
||||
"""A wall-clock stamp in `zone`, as an instant.
|
||||
|
||||
Naive input is *interpreted* in `zone`; aware input is converted, so a
|
||||
caller that already knows the offset cannot have it silently reassigned.
|
||||
"""
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=zone)
|
||||
return moment.astimezone(UTC)
|
||||
|
||||
|
||||
def as_utc(moment: datetime) -> datetime:
|
||||
"""An instant, whatever it arrived as.
|
||||
|
||||
SQLite does not store the offset, so a row read back from disk is naive
|
||||
while one still in the session's identity map keeps its tzinfo, and
|
||||
comparing the two raises -- the same trap `compaction.moment` exists for.
|
||||
A naive stamp from the database is UTC by construction, because that is what
|
||||
every column here is written with.
|
||||
"""
|
||||
if moment.tzinfo is None:
|
||||
return moment.replace(tzinfo=UTC)
|
||||
return moment.astimezone(UTC)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Turning "remind me every Monday to check the build" into a schedule.
|
||||
|
||||
One request, once, when a schedule is created. It does two things a person
|
||||
should not have to do by hand: work out the recurrence, and rewrite the
|
||||
description into something that reads sensibly with **no conversation around
|
||||
it** — because that is how it will be read, days later, by a model that was not
|
||||
present when it was typed.
|
||||
|
||||
Three rules hold this up:
|
||||
|
||||
- **The rule goes through `rule.validate` and nothing else.** That function is
|
||||
total and clamping, and this is the reason it had to be: what arrives here is
|
||||
model output that becomes a *timer*. There is one normaliser, shared with the
|
||||
manual form, so there cannot be two ideas of what a legal schedule is.
|
||||
- **A compile that fails is not an error.** It hands back what it could work out
|
||||
and the caller shows the manual form with the reader's own words in it. A
|
||||
model that answers in prose must never quietly produce a schedule that never
|
||||
fires.
|
||||
- **Clearing `task.schedule_compile` switches off the compiling, not the
|
||||
feature.** That is what makes "an empty override means off" safe here, and it
|
||||
is only safe because the manual form exists. `task.compact` set the precedent
|
||||
that clearing a fragment kills a feature, so this one says otherwise in its
|
||||
own hint.
|
||||
|
||||
Deliberately no `response_format`. Several local endpoints reject unknown
|
||||
parameters outright, and this is exactly the `apply_effort` lesson: a request
|
||||
that 400s here would be the compile silently switching itself off.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from lembas.db.models import TARGET_CHAT, TARGETS, Chat, User
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
||||
from lembas.services.reasoning import strip_reasoning
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Enough for a small model that thinks before answering. The title lesson
|
||||
# applies: too small is not a shorter answer, it is no answer, because the
|
||||
# thinking consumes the budget and content comes back empty.
|
||||
MAX_TOKENS = 900
|
||||
MAX_REQUEST_CHARS = 2000
|
||||
|
||||
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Compiled:
|
||||
"""What the compile worked out. `ok` is False when the reader must finish
|
||||
the job by hand -- the fields are still filled in as far as they went."""
|
||||
|
||||
ok: bool = False
|
||||
title: str = ""
|
||||
instruction: str = ""
|
||||
target: str = TARGET_CHAT
|
||||
rule: dict = field(default_factory=dict)
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def _payload(raw: str) -> dict:
|
||||
"""The first JSON object in a reply, however it was wrapped.
|
||||
|
||||
Lenient for the reason `tools.parse_arguments` is: a small model sends
|
||||
something close to the shape rather than the shape, and refusing it costs a
|
||||
whole round trip to end up showing the manual form anyway.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
fenced = _FENCE.search(text)
|
||||
if fenced:
|
||||
text = fenced.group(1).strip()
|
||||
if not text.startswith("{"):
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
return {}
|
||||
text = text[start : end + 1]
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def render_prompt(template: str, *, request: str, user: User | None) -> str:
|
||||
"""Fill the fragment in. Separate so a test can read what was asked."""
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
zone = clock.zone_for(user)
|
||||
now = datetime.now(tz=UTC).astimezone(zone)
|
||||
return prompts_service.substitute(
|
||||
template,
|
||||
{
|
||||
"request": request[:MAX_REQUEST_CHARS],
|
||||
"now": now.strftime("%A %-d %B %Y, %H:%M"),
|
||||
"timezone": clock.name_for(user) or str(clock.server_zone()),
|
||||
"targets": ", ".join(TARGETS),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def compile_request(
|
||||
endpoint: Endpoint,
|
||||
model_id: str,
|
||||
request: str,
|
||||
*,
|
||||
template: str,
|
||||
user: User | None = None,
|
||||
) -> Compiled:
|
||||
"""Work a plain-language request into a schedule.
|
||||
|
||||
Never raises. Every failure -- a cleared fragment, an endpoint that is down,
|
||||
prose instead of JSON, a rule that normalises to nothing -- comes back as
|
||||
`ok=False` with whatever was salvageable, and the route shows the manual form.
|
||||
"""
|
||||
plain = (request or "").strip()
|
||||
if not plain:
|
||||
return Compiled(reason="Say what you want to happen.")
|
||||
if not template.strip():
|
||||
# An administrator cleared the fragment. That switches off the
|
||||
# *compiling*: the reader fills the form in themselves, with their own
|
||||
# words already in it.
|
||||
return Compiled(instruction=plain, title=plain[:80], reason="")
|
||||
|
||||
prompt = render_prompt(template, request=plain, user=user)
|
||||
body = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": MAX_TOKENS,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
try:
|
||||
raw = await complete(endpoint, body)
|
||||
except LLMError as exc:
|
||||
log.info("schedule compile failed: %s", exc)
|
||||
return Compiled(
|
||||
instruction=plain,
|
||||
title=plain[:80],
|
||||
reason="The model could not be reached, so fill this in yourself.",
|
||||
)
|
||||
|
||||
# A model that thinks inline puts its reasoning in `content`, which is the
|
||||
# field `complete` hands back verbatim -- the same trap auto-titling hit.
|
||||
answered, _ = strip_reasoning(raw)
|
||||
payload = _payload(answered)
|
||||
if not payload:
|
||||
return Compiled(
|
||||
instruction=plain,
|
||||
title=plain[:80],
|
||||
reason="The model did not answer with a schedule, so fill this in yourself.",
|
||||
)
|
||||
|
||||
raw_rule = payload.get("schedule") or payload.get("rule") or {}
|
||||
if isinstance(raw_rule, dict):
|
||||
# A model asked for "every six hours" writes `{"every": {"hours": 6}}`
|
||||
# and nothing else, which is the natural reading and cannot fire: a
|
||||
# timer measures from a start, and `rule.py` has no clock to invent one.
|
||||
# Filled in here, exactly as the manual form's `_rule_from_form` does,
|
||||
# so the two paths agree about what a startless timer means. A model
|
||||
# that puts `start` at the top level instead is read the same way rather
|
||||
# than being told its schedule means nothing.
|
||||
raw_rule = dict(raw_rule)
|
||||
if raw_rule.get("every") and not raw_rule.get("start"):
|
||||
raw_rule["start"] = payload.get("start") or datetime.now(tz=UTC).isoformat()
|
||||
clean = rule_service.validate(raw_rule)
|
||||
title = str(payload.get("title") or "").strip() or plain[:80]
|
||||
instruction = str(payload.get("instruction") or "").strip() or plain
|
||||
target = str(payload.get("target") or TARGET_CHAT)
|
||||
if target not in TARGETS:
|
||||
target = TARGET_CHAT
|
||||
|
||||
if not clean:
|
||||
return Compiled(
|
||||
title=title,
|
||||
instruction=instruction,
|
||||
target=target,
|
||||
reason="The model could not work out when this should run — say when below.",
|
||||
)
|
||||
if rule_service.next_after(clean, datetime.now(tz=UTC), zone=clock.zone_for(user)) is None:
|
||||
# Normalised, but with nothing left to fire. Refused for the same reason
|
||||
# `schedules.create` refuses it: a schedule that can never run looks
|
||||
# exactly like a working one on every screen it appears on.
|
||||
return Compiled(
|
||||
title=title,
|
||||
instruction=instruction,
|
||||
target=target,
|
||||
rule=clean,
|
||||
reason="That time has already passed — say when it should run.",
|
||||
)
|
||||
|
||||
return Compiled(ok=True, title=title, instruction=instruction, target=target, rule=clean)
|
||||
|
||||
|
||||
def endpoint_for(db, user: User) -> tuple[Endpoint, str] | None:
|
||||
"""A connection and model to compile with, or None if there is none.
|
||||
|
||||
Built on a throwaway `Chat` that is never added to a session, exactly as
|
||||
`agent/draft.py` does: `resolve_endpoint` reads `model_id` and
|
||||
`connection_id` and nothing else, so it works unchanged and did not have to
|
||||
learn what a compile is.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
models = chat_service.available_models(db, user)
|
||||
if not models:
|
||||
return None
|
||||
chosen = next((m for m in models if m.pinned), models[0])
|
||||
stand_in = Chat(user_id=user.id, model_id=chosen.model_id, connection_id=chosen.connection_id)
|
||||
try:
|
||||
return chat_service.resolve_endpoint(db, stand_in)
|
||||
except LLMError:
|
||||
return None
|
||||
@@ -0,0 +1,506 @@
|
||||
"""When a schedule next comes due.
|
||||
|
||||
Pure and total. Nothing here opens a session, reads the wall clock or raises:
|
||||
every function takes what it needs and answers, so the whole of this module can
|
||||
be tested exhaustively before anything calls it. That is deliberate, because
|
||||
everything downstream fails *quietly* -- a schedule that never fires looks
|
||||
exactly like a working one on the list page, and a schedule that fires an hour
|
||||
out looks like nothing at all until somebody notices the report is late.
|
||||
|
||||
## The shape
|
||||
|
||||
Plain cron cannot say "ten minutes from now, five times", so the rule is a dict
|
||||
with two independent generators and a bound:
|
||||
|
||||
{
|
||||
"start": "2026-08-05T14:30:00Z", # first candidate instant, UTC
|
||||
"every": {"minutes": 10}, # a stride
|
||||
"at": {"weekdays": [0], # 0 = Monday
|
||||
"days": [1, 15], # day of the month
|
||||
"months": [1, 4, 7, 10],
|
||||
"times": ["15:00"]}, # wall-clock, in the owner's zone
|
||||
"count": 5, # total firings, 0 = unbounded
|
||||
"until": "2026-12-31T00:00:00Z" # last instant, "" = unbounded
|
||||
}
|
||||
|
||||
`every` and `at` compose, and the four combinations are the whole vocabulary:
|
||||
|
||||
every at meaning
|
||||
----- ---- ------------------------------------------------------------
|
||||
- - fire once, at `start`
|
||||
x - a timer: start, start + every, start + 2*every, ...
|
||||
- x a calendar: every matching wall-clock moment after `start`
|
||||
x x a calendar with a stride: matching moments, every Nth kept
|
||||
|
||||
## Timezone, and why the two halves differ
|
||||
|
||||
`at.times` are **wall-clock** in the owner's zone: 15:00 stays 15:00 across a
|
||||
DST change, because that is what "every Monday at 3PM" means to the person who
|
||||
said it. `every` durations are **elapsed real time**: ten minutes is ten
|
||||
minutes, and a six-hourly timer must not skip or double on a 23- or 25-hour day.
|
||||
Those are different meanings, not an inconsistency, and conflating them is how
|
||||
one of the two comes out wrong twice a year.
|
||||
|
||||
A wall-clock time that does not exist (the hour skipped on a spring-forward day)
|
||||
fires at the first instant that does, rather than being skipped -- a daily report
|
||||
vanishing once a year is precisely the silent failure this file exists to avoid.
|
||||
One that occurs twice on a fall-back day fires on the first, once.
|
||||
|
||||
## Not expressible
|
||||
|
||||
Said plainly, because the gap is the point: "the last Friday of the month", "the
|
||||
third Monday", "weekdays except holidays", "the Nth business day", sub-minute
|
||||
intervals, sunrise-relative times, and any conditional firing ("only if the
|
||||
build is red"). The first two are what people will actually ask for; the rule is
|
||||
JSON, so an `nth` key inside `at` adds them later with no migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta, tzinfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The stride units, and how many seconds each is worth. Months are absent on
|
||||
# purpose: a month is not a duration, and "every month" is `at: {days: [n]}`,
|
||||
# which is what somebody means by it.
|
||||
UNITS: dict[str, int] = {
|
||||
"minutes": 60,
|
||||
"hours": 3600,
|
||||
"days": 86400,
|
||||
"weeks": 604800,
|
||||
}
|
||||
|
||||
# Bounds. Every one of these is a clamp rather than a rejection, because the
|
||||
# rule can arrive from a *model* -- the compile step's output is model output
|
||||
# that becomes a timer, and `validate` is this feature's `nh3.clean`.
|
||||
MIN_INTERVAL_SECONDS = 60
|
||||
MAX_INTERVAL_SECONDS = 366 * 86400
|
||||
MAX_COUNT = 10_000
|
||||
MAX_TIMES = 24
|
||||
MAX_HORIZON_DAYS = 366 * 5
|
||||
|
||||
# How far ahead a calendar search will walk before giving up. A rule asking for
|
||||
# 31 February matches nothing, and a search with no bound would spin for ever
|
||||
# inside the ticker. Days rather than iterations, so the limit is a statement
|
||||
# about the schedule rather than about the loop.
|
||||
SEARCH_DAYS = 366 * 4
|
||||
|
||||
WEEKDAYS = (0, 1, 2, 3, 4, 5, 6)
|
||||
|
||||
|
||||
# --- Reading a rule ------------------------------------------------------------
|
||||
def _int(value: object, *, low: int, high: int, default: int = 0) -> int:
|
||||
try:
|
||||
number = int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(low, min(number, high))
|
||||
|
||||
|
||||
def _stamp(value: object) -> datetime | None:
|
||||
"""An ISO instant, or None. Naive input is read as UTC.
|
||||
|
||||
`fromisoformat` handles a trailing Z from Python 3.11, but a model writes
|
||||
all sorts of things, so anything unparseable is simply absent.
|
||||
"""
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _times(value: object) -> list[tuple[int, int]]:
|
||||
"""Wall-clock times as (hour, minute), sorted and deduplicated.
|
||||
|
||||
Accepts "15:00", "15:00:30" and "9:5", because a model writes all three, and
|
||||
a rule refused for its punctuation is a round trip spent on nothing.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
value = [value]
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
found: set[tuple[int, int]] = set()
|
||||
for item in list(value)[:MAX_TIMES]:
|
||||
if not isinstance(item, str) or ":" not in item:
|
||||
continue
|
||||
hour, _, rest = item.strip().partition(":")
|
||||
minute = rest.partition(":")[0]
|
||||
try:
|
||||
pair = (int(hour), int(minute))
|
||||
except ValueError:
|
||||
continue
|
||||
if 0 <= pair[0] <= 23 and 0 <= pair[1] <= 59:
|
||||
found.add(pair)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _numbers(value: object, *, low: int, high: int) -> list[int]:
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
value = [value]
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
found: set[int] = set()
|
||||
for item in value:
|
||||
if isinstance(item, bool):
|
||||
continue
|
||||
try:
|
||||
number = int(item) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if low <= number <= high:
|
||||
found.add(number)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _every(value: object) -> dict[str, int]:
|
||||
"""A stride, clamped to something that can actually be run.
|
||||
|
||||
An interval under a minute is refused rather than clamped to a minute: the
|
||||
ticker's own granularity is coarser than that, so honouring it is impossible
|
||||
and pretending to would be a schedule that silently runs late for ever.
|
||||
Clamped up, because "every 10 seconds" from a model means "often", and often
|
||||
is a minute.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
seconds = 0
|
||||
for unit, size in UNITS.items():
|
||||
seconds += _int(value.get(unit), low=0, high=MAX_INTERVAL_SECONDS) * size
|
||||
if seconds <= 0:
|
||||
return {}
|
||||
seconds = max(MIN_INTERVAL_SECONDS, min(seconds, MAX_INTERVAL_SECONDS))
|
||||
return {"minutes": seconds // 60}
|
||||
|
||||
|
||||
def validate(rule: object) -> dict:
|
||||
"""Normalise a rule, or return {} for one that cannot be made sense of.
|
||||
|
||||
**Total on purpose.** The compile step hands this whatever a model wrote, so
|
||||
it drops what it does not recognise and clamps what it does, and never
|
||||
raises. `{}` is the honest answer for prose, for a cron string, for an empty
|
||||
object -- and the caller's job is then to show the manual form rather than
|
||||
write a schedule that never fires. A schedule that can never fire is
|
||||
indistinguishable from a working one on every screen it appears on, which is
|
||||
this feature's flagship silent failure.
|
||||
|
||||
The invariant worth holding on to, and pinned in the tests: **anything this
|
||||
returns non-empty has a computable next occurrence.**
|
||||
"""
|
||||
if not isinstance(rule, dict):
|
||||
return {}
|
||||
|
||||
every = _every(rule.get("every"))
|
||||
raw_at = rule.get("at") if isinstance(rule.get("at"), dict) else {}
|
||||
at = {
|
||||
"weekdays": _numbers(raw_at.get("weekdays"), low=0, high=6),
|
||||
"days": _numbers(raw_at.get("days"), low=1, high=31),
|
||||
"months": _numbers(raw_at.get("months"), low=1, high=12),
|
||||
"times": [f"{hour:02d}:{minute:02d}" for hour, minute in _times(raw_at.get("times"))],
|
||||
}
|
||||
# A calendar with no time of day has no time of day. Midnight is the only
|
||||
# defensible reading and it is what every cron-like thing does, so it is
|
||||
# filled in rather than making the whole `at` block meaningless.
|
||||
if any(at[key] for key in ("weekdays", "days", "months")) and not at["times"]:
|
||||
at["times"] = ["00:00"]
|
||||
if not at["times"]:
|
||||
at = {}
|
||||
|
||||
start = _stamp(rule.get("start"))
|
||||
until = _stamp(rule.get("until"))
|
||||
count = _int(rule.get("count"), low=0, high=MAX_COUNT)
|
||||
|
||||
# A one-shot is `start` and nothing else, so without a start there is
|
||||
# nothing to fire and nothing to infer -- unlike a calendar, which is
|
||||
# perfectly meaningful from now onwards.
|
||||
if not every and not at and start is None:
|
||||
return {}
|
||||
# A window that closes before it opens produces nothing, which is a rule
|
||||
# that cannot fire rather than one that fires oddly.
|
||||
if start is not None and until is not None and until < start:
|
||||
return {}
|
||||
|
||||
normalised: dict = {}
|
||||
if start is not None:
|
||||
normalised["start"] = start.astimezone(UTC).isoformat()
|
||||
if every:
|
||||
normalised["every"] = every
|
||||
if at:
|
||||
normalised["at"] = {key: value for key, value in at.items() if value}
|
||||
normalised["at"]["times"] = at["times"]
|
||||
if count:
|
||||
normalised["count"] = count
|
||||
if until is not None:
|
||||
normalised["until"] = until.astimezone(UTC).isoformat()
|
||||
return normalised
|
||||
|
||||
|
||||
# --- When it next comes due -----------------------------------------------------
|
||||
def _interval(rule: dict) -> timedelta:
|
||||
return timedelta(minutes=int((rule.get("every") or {}).get("minutes") or 0))
|
||||
|
||||
|
||||
def _matches(moment: datetime, at: dict) -> bool:
|
||||
"""Whether a local date satisfies the calendar constraints.
|
||||
|
||||
Empty means "every", per field, which is what makes `{"times": ["09:00"]}`
|
||||
read as "daily at nine" without having to enumerate seven weekdays.
|
||||
"""
|
||||
weekdays = at.get("weekdays") or []
|
||||
days = at.get("days") or []
|
||||
months = at.get("months") or []
|
||||
if weekdays and moment.weekday() not in weekdays:
|
||||
return False
|
||||
if days and moment.day not in days:
|
||||
return False
|
||||
return not (months and moment.month not in months)
|
||||
|
||||
|
||||
def _wall(day: datetime, hour: int, minute: int, zone: tzinfo) -> datetime:
|
||||
"""A wall-clock time on a given local day, as an instant.
|
||||
|
||||
Two DST cases, both handled here rather than left to `zoneinfo`'s defaults:
|
||||
|
||||
- **The hour that does not exist.** On a spring-forward day, 02:30 is not a
|
||||
time. Constructing it anyway yields something that does not round-trip, so
|
||||
the gap is detected by comparing and the result is pushed to the first
|
||||
instant that does exist. Skipping the day instead is how a daily report
|
||||
disappears once a year.
|
||||
- **The hour that happens twice.** `fold=0` picks the first, and the
|
||||
advance-past-the-last-fire rule upstream is what stops the second being
|
||||
taken as a separate occurrence.
|
||||
"""
|
||||
naive = day.replace(hour=hour, minute=minute, second=0, microsecond=0, tzinfo=None)
|
||||
local = naive.replace(tzinfo=zone, fold=0)
|
||||
# A time inside the spring-forward gap does not survive the round trip.
|
||||
if local.astimezone(UTC).astimezone(zone).replace(tzinfo=None) != naive:
|
||||
# Walk forward a minute at a time to the far side of the gap. Gaps are
|
||||
# an hour at most in every zone the database has ever carried, so this
|
||||
# is bounded and cheap; adding the offset difference directly would
|
||||
# assume the size of a gap this code has no business knowing.
|
||||
for extra in range(1, 181):
|
||||
candidate = (naive + timedelta(minutes=extra)).replace(tzinfo=zone, fold=0)
|
||||
round_trip = candidate.astimezone(UTC).astimezone(zone).replace(tzinfo=None)
|
||||
if round_trip == naive + timedelta(minutes=extra):
|
||||
return candidate.astimezone(UTC)
|
||||
return local.astimezone(UTC)
|
||||
|
||||
|
||||
def _calendar_after(rule: dict, after: datetime, *, zone: tzinfo) -> datetime | None:
|
||||
"""The first calendar occurrence strictly after `after`."""
|
||||
at = rule.get("at") or {}
|
||||
times = [tuple(int(part) for part in value.split(":")) for value in at.get("times") or []]
|
||||
if not times:
|
||||
return None
|
||||
|
||||
local = after.astimezone(zone)
|
||||
day = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
for _ in range(SEARCH_DAYS):
|
||||
if _matches(day, at):
|
||||
for hour, minute in times:
|
||||
moment = _wall(day, hour, minute, zone)
|
||||
if moment > after:
|
||||
return moment
|
||||
day += timedelta(days=1)
|
||||
# Re-anchor to local midnight: adding a day across a DST boundary
|
||||
# otherwise leaves the cursor an hour either side of it, and the day
|
||||
# after a fall-back would be searched from 23:00 the previous evening.
|
||||
day = day.astimezone(zone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return None
|
||||
|
||||
|
||||
def _exhausted(rule: dict, moment: datetime, fired: int) -> bool:
|
||||
count = int(rule.get("count") or 0)
|
||||
if count and fired >= count:
|
||||
return True
|
||||
until = _stamp(rule.get("until"))
|
||||
return bool(until and moment > until)
|
||||
|
||||
|
||||
def next_after(
|
||||
rule: dict, after: datetime, *, zone: tzinfo, fired: int = 0
|
||||
) -> datetime | None:
|
||||
"""The next instant this rule comes due, strictly after `after`.
|
||||
|
||||
`None` means never again: the count is spent, the window has closed, or the
|
||||
calendar matches nothing inside the search horizon. A caller seeing `None`
|
||||
disables the schedule -- exhaustion switches off, it does not loop.
|
||||
|
||||
`fired` is how many times it has already run, and is what makes `count`
|
||||
work without the rule having to carry mutable state.
|
||||
"""
|
||||
if not isinstance(rule, dict) or not rule:
|
||||
return None
|
||||
count = int(rule.get("count") or 0)
|
||||
if count and fired >= count:
|
||||
return None
|
||||
|
||||
after = after.astimezone(UTC)
|
||||
start = _stamp(rule.get("start"))
|
||||
every = _interval(rule)
|
||||
at = rule.get("at") or {}
|
||||
|
||||
moment: datetime | None
|
||||
if at:
|
||||
# A calendar never fires before its start, so the search begins at
|
||||
# whichever of the two is later.
|
||||
floor = max(after, start - timedelta(microseconds=1)) if start else after
|
||||
moment = _calendar_after(rule, floor, zone=zone)
|
||||
if moment is not None and every:
|
||||
# A stride over a calendar keeps every Nth match. Counted from the
|
||||
# start rather than from `after`, so "every other Monday" means the
|
||||
# same two Mondays whenever it is asked.
|
||||
stride = max(1, int(round(every.total_seconds() / 86400)) or 1)
|
||||
if stride > 1 and start is not None:
|
||||
elapsed = (moment.astimezone(zone).date() - start.astimezone(zone).date()).days
|
||||
skipped = 0
|
||||
while elapsed % stride and skipped < SEARCH_DAYS:
|
||||
moment = _calendar_after(rule, moment, zone=zone)
|
||||
if moment is None:
|
||||
break
|
||||
elapsed = (
|
||||
moment.astimezone(zone).date() - start.astimezone(zone).date()
|
||||
).days
|
||||
skipped += 1
|
||||
elif every:
|
||||
if start is None:
|
||||
return None
|
||||
if after < start:
|
||||
moment = start
|
||||
else:
|
||||
# Absolute arithmetic, deliberately: a timer measures elapsed time,
|
||||
# so it must not shift when the offset does. Computed rather than
|
||||
# stepped, so a schedule idle for a year costs one division.
|
||||
elapsed = (after - start).total_seconds()
|
||||
steps = int(elapsed // every.total_seconds()) + 1
|
||||
moment = start + every * steps
|
||||
else:
|
||||
# A one-shot. Due exactly once, and only if it has not already run --
|
||||
# `fired` is what stops it being re-offered for ever once its moment has
|
||||
# passed, since `start > after` is false from then on.
|
||||
if start is None or fired:
|
||||
return None
|
||||
moment = start if start > after else None
|
||||
|
||||
if moment is None or _exhausted(rule, moment, fired):
|
||||
return None
|
||||
return moment
|
||||
|
||||
|
||||
def advance(
|
||||
rule: dict, *, after: datetime, now: datetime, zone: tzinfo, fired: int = 0
|
||||
) -> tuple[bool, datetime | None]:
|
||||
"""Catch up on a schedule whose time passed while nothing was running.
|
||||
|
||||
Answers two things at once: whether it is owed a firing *now*, and when it
|
||||
should next come due. The pair is one function because the second depends on
|
||||
the first -- a caller that asked separately would have to decide what
|
||||
"next" means for a schedule it has just decided to fire.
|
||||
|
||||
**A missed run collapses to one.** The next occurrence returned is the first
|
||||
one strictly after `now`, not the one after the slot that was missed -- so a
|
||||
host switched off for a week comes back owing one report rather than a
|
||||
hundred and sixty-eight. That is the whole reason this is not just
|
||||
`next_after`.
|
||||
|
||||
It is called from the *sweep* rather than only at startup, because a
|
||||
suspended laptop, a paused container and a long stall all reproduce the
|
||||
same situation with no restart to hang a startup hook on.
|
||||
"""
|
||||
due = next_after(rule, after, zone=zone, fired=fired)
|
||||
if due is None:
|
||||
return False, None
|
||||
if due > now:
|
||||
return False, due
|
||||
# Overdue. Fire once, and resume from wherever the rule is now -- counting
|
||||
# this firing, so `count` is spent by what actually ran.
|
||||
return True, next_after(rule, now, zone=zone, fired=fired + 1)
|
||||
|
||||
|
||||
# --- Saying it back -------------------------------------------------------------
|
||||
_DAY_NAMES = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
||||
_MONTH_NAMES = (
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
)
|
||||
|
||||
|
||||
def _join(words: list[str]) -> str:
|
||||
if len(words) <= 1:
|
||||
return "".join(words)
|
||||
return f"{', '.join(words[:-1])} and {words[-1]}"
|
||||
|
||||
|
||||
def _ordinal(number: int) -> str:
|
||||
if 10 <= number % 100 <= 20:
|
||||
return f"{number}th"
|
||||
return f"{number}{ {1: 'st', 2: 'nd', 3: 'rd'}.get(number % 10, 'th') }"
|
||||
|
||||
|
||||
def _duration(delta: timedelta) -> str:
|
||||
minutes = int(delta.total_seconds() // 60)
|
||||
for size, unit in ((10080, "week"), (1440, "day"), (60, "hour"), (1, "minute")):
|
||||
if minutes >= size and not minutes % size:
|
||||
amount = minutes // size
|
||||
return f"{amount} {unit}{'s' if amount != 1 else ''}"
|
||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||
|
||||
|
||||
def describe(rule: dict, *, zone: tzinfo) -> str:
|
||||
"""One line saying what this rule does, in the reader's own zone.
|
||||
|
||||
Not decoration. It is what the setup screen echoes back before anything is
|
||||
saved, what the list page shows beside each schedule, and what the harness
|
||||
tells a model about its own chat. A row reading "Every Monday at 3PM" over a
|
||||
rule that fires daily is the same class of failure as three places
|
||||
disagreeing about a tool's name -- and this one is the reader's only view of
|
||||
a decision that happens while they are not looking.
|
||||
"""
|
||||
rule = rule or {}
|
||||
if not rule:
|
||||
return "Never"
|
||||
|
||||
at = rule.get("at") or {}
|
||||
every = _interval(rule)
|
||||
parts: list[str] = []
|
||||
|
||||
if at:
|
||||
times = _join(list(at.get("times") or []))
|
||||
when = []
|
||||
if at.get("weekdays"):
|
||||
when.append(_join([_DAY_NAMES[day] for day in at["weekdays"]]))
|
||||
if at.get("days"):
|
||||
when.append(f"the {_join([_ordinal(day) for day in at['days']])}")
|
||||
if at.get("months"):
|
||||
when.append(f"of {_join([_MONTH_NAMES[month - 1] for month in at['months']])}")
|
||||
parts.append(
|
||||
f"Every {' '.join(when)} at {times}" if when else f"Every day at {times}"
|
||||
)
|
||||
# A stride over a calendar is a qualifier rather than a rewording:
|
||||
# "Every Monday at 15:00, skipping to every 14 days" is clumsy but true,
|
||||
# and inventing "every other Monday" for it would be a phrase that stops
|
||||
# being true the moment the stride is not two.
|
||||
stride_days = int(every.total_seconds() // 86400) if every else 0
|
||||
if stride_days > 1:
|
||||
parts.append(f"but only every {stride_days} days")
|
||||
elif every:
|
||||
parts.append(f"Every {_duration(every)}")
|
||||
else:
|
||||
start = _stamp(rule.get("start"))
|
||||
local = start.astimezone(zone) if start else None
|
||||
return f"Once, on {local.strftime('%-d %B %Y at %H:%M')}" if local else "Once"
|
||||
|
||||
count = int(rule.get("count") or 0)
|
||||
if count:
|
||||
parts.append(f"{count} time{'s' if count != 1 else ''}")
|
||||
until = _stamp(rule.get("until"))
|
||||
if until:
|
||||
parts.append(f"until {until.astimezone(zone).strftime('%-d %B %Y')}")
|
||||
|
||||
return ", ".join(parts)
|
||||
@@ -0,0 +1,301 @@
|
||||
"""What happens when a schedule fires.
|
||||
|
||||
Every schedule fires the same way — a turn into a chat, answered by the ordinary
|
||||
generation loop — and the *target* decides only what becomes of the finished
|
||||
reply. One mechanism, three deliveries:
|
||||
|
||||
- `chat` leave it there. The reply is the point, and it is already in the
|
||||
task chat where somebody will read it.
|
||||
- `report` copy it into a `Report` and keep the chat out of the way.
|
||||
- `messages` copy it into the reader's Messages conversation, as an assistant
|
||||
turn marked `machine`. Copied rather than moved: the task chat is
|
||||
the working area and keeps the tool calls, the steps and the
|
||||
metrics; Messages gets the answer.
|
||||
|
||||
The alternative — a one-shot `complete()` in the shape of `generate_title` — was
|
||||
rejected because it has no tools and no rounds, which is useless for the case
|
||||
this feature exists for. "Give me a daily news report" needs to search the web.
|
||||
|
||||
**Nothing in `services/generation.py` changes.** The waiting happens here, in a
|
||||
task per firing, which is the shape `jobs._watch` already established. Making
|
||||
generation aware of schedules would mean a branch inside `_persist`, and that is
|
||||
the single writer with one rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from lembas.db.models import (
|
||||
ROLE_ASSISTANT,
|
||||
TARGET_CHAT,
|
||||
TARGET_MESSAGES,
|
||||
TARGET_REPORT,
|
||||
Chat,
|
||||
Message,
|
||||
Schedule,
|
||||
User,
|
||||
)
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import wake as wake_service
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How long to wait for a firing's reply before giving up on delivering it. The
|
||||
# reply itself is not cancelled -- it goes on and lands in its chat, which is
|
||||
# where a task chat's output belongs anyway. What times out is only *this*
|
||||
# task's interest in copying the result somewhere.
|
||||
DELIVERY_TIMEOUT = 3600.0
|
||||
# How often the waiter looks. Coarse on purpose: nothing is watching this, and a
|
||||
# report arriving three seconds late costs nobody anything.
|
||||
POLL_SECONDS = 3.0
|
||||
|
||||
|
||||
def _preamble(schedule: Schedule, *, zone, due_at: datetime | None) -> str:
|
||||
"""The turn a firing puts into the chat.
|
||||
|
||||
Names itself a scheduled event in *words*, because the role stays `user` --
|
||||
`_inject` sends a queued turn verbatim and `build_messages` must keep seeing
|
||||
a user turn. The framing therefore cannot live in the role, exactly as it
|
||||
cannot for a finished background job.
|
||||
|
||||
The scheduled time is stated as well as the actual one, so a run caught up
|
||||
after an outage can say so rather than reporting stale news as current.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).astimezone(zone)
|
||||
lines = [
|
||||
"This turn was started by a schedule, not by the person — "
|
||||
"they are not necessarily at the keyboard.",
|
||||
"",
|
||||
f"[schedule: {schedule.title or 'untitled'}] "
|
||||
f"{rule_service.describe(schedule.rule_json or {}, zone=zone)}",
|
||||
f"It is now {now.strftime('%A %-d %B %Y, %H:%M')}.",
|
||||
]
|
||||
if due_at is not None:
|
||||
late = (datetime.now(tz=UTC) - clock.as_utc(due_at)).total_seconds()
|
||||
if late > 600:
|
||||
local = clock.as_utc(due_at).astimezone(zone)
|
||||
lines.append(
|
||||
f"This run was due at {local.strftime('%A %-d %B, %H:%M')} and is late — "
|
||||
"say so if it makes any of what follows out of date."
|
||||
)
|
||||
lines += ["", schedule.instruction or schedule.request or ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _await_reply(chat_id: str, message_id: str) -> None:
|
||||
"""Wait for one generation to finish.
|
||||
|
||||
Polled rather than awaited on the task itself: `generation` owns its
|
||||
registry and its tasks, and reaching into either from here would couple this
|
||||
to internals whose whole job is to be replaceable. A poll costs nothing at
|
||||
this interval and cannot deadlock.
|
||||
"""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
waited = 0.0
|
||||
while waited < DELIVERY_TIMEOUT:
|
||||
running = generation_service.running_for(chat_id)
|
||||
# `running_for` already excludes a finished generation, so `None` is the
|
||||
# ordinary end of this loop. The id check is what stops us waiting on
|
||||
# somebody's *next* reply in the same chat, which would otherwise happen
|
||||
# whenever a queued turn is drained straight after ours.
|
||||
if running is None or running.message_id != message_id:
|
||||
return
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
waited += POLL_SECONDS
|
||||
log.warning("gave up waiting for the reply to schedule message %s", message_id)
|
||||
|
||||
|
||||
def _finished_reply(db, chat_id: str, message_id: str) -> Message | None:
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat_id:
|
||||
return None
|
||||
if not message.complete or message.error:
|
||||
return None
|
||||
return message
|
||||
|
||||
|
||||
async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None:
|
||||
"""Put a finished reply where the schedule said it should go.
|
||||
|
||||
`since` is the moment the firing began, and it is what tells a report the
|
||||
model filed itself apart from one filed on a previous run.
|
||||
"""
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is None:
|
||||
return
|
||||
target = schedule.target
|
||||
chat_id = schedule.chat_id
|
||||
|
||||
if target == TARGET_CHAT:
|
||||
# Already where it belongs. Stated rather than left to fall through, so
|
||||
# a reader of this function does not have to infer the common case.
|
||||
return
|
||||
|
||||
await _await_reply(chat_id, message_id)
|
||||
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is None:
|
||||
return
|
||||
owner = db.get(User, schedule.user_id)
|
||||
if owner is None:
|
||||
return
|
||||
message = _finished_reply(db, chat_id, message_id)
|
||||
|
||||
if target == TARGET_REPORT:
|
||||
# If the model filed one itself with `report_write`, that is the
|
||||
# report and this must not file a second. The tool stamps
|
||||
# `source_id` with the chat, which is what makes them the same run;
|
||||
# `since` is what makes it *this* run. Both stamps go through
|
||||
# `as_utc` because one comes from a row read back from SQLite (which
|
||||
# loses the offset) and the other is still in memory -- comparing
|
||||
# the two raises, the trap `compaction.moment` exists for.
|
||||
already = reports_service.recent(db, owner, limit=5)
|
||||
if any(
|
||||
r.source_id == chat_id and clock.as_utc(r.created_at) >= clock.as_utc(since)
|
||||
for r in already
|
||||
):
|
||||
return
|
||||
if message is None:
|
||||
reports_service.create(
|
||||
db,
|
||||
owner=owner,
|
||||
title=schedule.title or "Scheduled run",
|
||||
body="",
|
||||
source="schedule",
|
||||
source_id=chat_id,
|
||||
schedule_id=schedule.id,
|
||||
error="The run did not produce a reply.",
|
||||
)
|
||||
return
|
||||
reports_service.create(
|
||||
db,
|
||||
owner=owner,
|
||||
title=schedule.title or "Scheduled run",
|
||||
body=message.content or "",
|
||||
source="schedule",
|
||||
source_id=chat_id,
|
||||
schedule_id=schedule.id,
|
||||
model_id=message.model_id or "",
|
||||
)
|
||||
return
|
||||
|
||||
if target == TARGET_MESSAGES:
|
||||
if message is None:
|
||||
schedule.last_error = "The run did not produce anything to post."
|
||||
db.commit()
|
||||
return
|
||||
# Copied in as an assistant turn rather than moved, because the task
|
||||
# chat is the working area and holds the tool calls, the steps and
|
||||
# the metrics -- the Messages conversation gets the answer. Marked
|
||||
# `machine` for the same reason a job completion is: the reader did
|
||||
# not write it, and the bubble should not imply they did.
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import messages as messages_service
|
||||
|
||||
conversation = messages_service.for_user(db, owner)
|
||||
chat_service.create_message(
|
||||
db,
|
||||
conversation,
|
||||
ROLE_ASSISTANT,
|
||||
message.content or "",
|
||||
model_id=message.model_id or "",
|
||||
machine=True,
|
||||
)
|
||||
conversation.unread = True
|
||||
conversation.unread_notified = False
|
||||
db.commit()
|
||||
|
||||
|
||||
async def fire(schedule_id: str, *, due_at: datetime | None = None) -> None:
|
||||
"""Run one schedule now.
|
||||
|
||||
Never raises: the ticker calls this and one bad schedule must not stop the
|
||||
others. Anything that goes wrong is written to `last_error`, where the
|
||||
schedule's own page shows it — a run that failed silently is
|
||||
indistinguishable from one that was never due.
|
||||
"""
|
||||
from lembas.services import settings_store
|
||||
|
||||
try:
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is None:
|
||||
return
|
||||
owner = db.get(User, schedule.user_id)
|
||||
chat = db.get(Chat, schedule.chat_id) if schedule.chat_id else None
|
||||
if owner is None:
|
||||
return
|
||||
if chat is None or chat.user_id != owner.id:
|
||||
# The chat was deleted, or never belonged to this owner. Stop
|
||||
# rather than fire into nothing on every tick from now on.
|
||||
schedule.enabled = False
|
||||
schedule.last_error = "Its chat no longer exists, so it has been switched off."
|
||||
db.commit()
|
||||
return
|
||||
|
||||
limit = int(settings_store.schedules(db).get("max_queued") or 3)
|
||||
zone = clock.zone_for(owner)
|
||||
content = _preamble(schedule, zone=zone, due_at=due_at)
|
||||
chat_id = chat.id
|
||||
model_id = schedule.model_id or chat.model_id
|
||||
began = datetime.now(tz=UTC)
|
||||
schedule.claimed_at = began
|
||||
schedule.last_error = ""
|
||||
db.commit()
|
||||
|
||||
# Outside the session: a chat already carrying a backlog is one whose
|
||||
# replies are slower than its schedule, and adding to it makes that
|
||||
# permanently worse. `_drain` takes one queued turn per reply.
|
||||
if wake_service.queued_count(chat_id) >= limit:
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is not None:
|
||||
schedule.last_error = (
|
||||
"Skipped: the previous run was still going, and turns are "
|
||||
"already waiting in its chat."
|
||||
)
|
||||
schedule.claimed_at = None
|
||||
db.commit()
|
||||
return
|
||||
|
||||
message_id = await wake_service.wake_chat(chat_id, content, model_id=model_id)
|
||||
|
||||
with session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is not None:
|
||||
schedule.claimed_at = None
|
||||
db.commit()
|
||||
|
||||
if message_id:
|
||||
await deliver(schedule_id, message_id, since=began)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - one bad schedule must not stop the rest
|
||||
log.exception("schedule %s failed to fire", schedule_id)
|
||||
with contextlib.suppress(Exception), session_scope() as db:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is not None:
|
||||
schedule.last_error = "Something went wrong running this. See the log."
|
||||
schedule.claimed_at = None
|
||||
db.commit()
|
||||
|
||||
|
||||
async def run_now(schedule_id: str) -> None:
|
||||
"""Fire a schedule because somebody pressed the button.
|
||||
|
||||
**Deliberately does not advance `next_fire_at`.** Testing a schedule must
|
||||
not consume the run it was testing -- somebody who presses this at 14:00 to
|
||||
check a 15:00 report still expects the 15:00 one. The ticker owns advancing,
|
||||
and it is the only thing that does.
|
||||
"""
|
||||
await fire(schedule_id)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""The loop that notices a schedule is due, and claims it.
|
||||
|
||||
Modelled on `agent/terminal.py:_reaper_loop`, which is the only periodic task
|
||||
this codebase had before now — including the blanket `except` around the sweep,
|
||||
for a reason that is sharper here: **a ticker that dies on one bad row stops
|
||||
every schedule on the instance, and says nothing.** Nothing else would notice.
|
||||
There is no request failing, no reply erroring, no dot appearing. The reports
|
||||
simply stop, and the first person to find out is whoever eventually wonders why.
|
||||
|
||||
Started from the lifespan rather than lazily like the reaper. Lazy is right for
|
||||
terminals — a shell only exists once somebody opened one — and wrong here: a
|
||||
schedule can be due at startup with nobody logged in, which is most of the point.
|
||||
|
||||
## Claiming, and why the order is the whole design
|
||||
|
||||
One worker and one loop, so the risk is not two processes racing; it is two
|
||||
*overlapping sweeps*, and a firing that raises being retried every tick for ever.
|
||||
Three things answer that:
|
||||
|
||||
1. A lock around the sweep, so a slow one (a firing awaits a model, which can
|
||||
take minutes) cannot overlap the next tick.
|
||||
2. **Advance, then fire.** The row is moved on and committed *before* anything
|
||||
is awaited. A firing that dies has still consumed its slot, so the schedule
|
||||
resumes at its next occurrence with the reason on the row — rather than
|
||||
becoming a hot loop against an endpoint that is down.
|
||||
3. `claimed_at` outliving a firing is what lets a run that never finished say so
|
||||
instead of looking like one that never started.
|
||||
|
||||
Exhaustion **disables**: a rule with nothing left returns `None`, and the row is
|
||||
switched off rather than being re-examined for ever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Schedule, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.schedule import clock, runner
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TICKER: asyncio.Task | None = None
|
||||
_SWEEPING = asyncio.Lock()
|
||||
# Live firings, so shutdown can wait for them rather than leaving a half-written
|
||||
# reply and a `claimed_at` that never clears.
|
||||
_FIRING: set[asyncio.Task] = set()
|
||||
|
||||
# Fallback when nothing has been configured. `settings_store.schedules` clamps
|
||||
# the stored value; this is only for a sweep that runs before anything is read.
|
||||
TICK_SECONDS = 30.0
|
||||
|
||||
|
||||
def _due(now: datetime):
|
||||
return (
|
||||
select(Schedule)
|
||||
.where(
|
||||
Schedule.enabled.is_(True),
|
||||
Schedule.next_fire_at.is_not(None),
|
||||
Schedule.next_fire_at <= now,
|
||||
)
|
||||
.order_by(Schedule.next_fire_at)
|
||||
)
|
||||
|
||||
|
||||
def claim(schedule: Schedule, *, now: datetime, zone) -> tuple[bool, datetime | None]:
|
||||
"""Move one schedule on, and say whether it is owed a firing.
|
||||
|
||||
Pure bookkeeping on the row: it does not fire anything and does not commit,
|
||||
so the caller decides the transaction boundary. The caller must commit
|
||||
before awaiting.
|
||||
"""
|
||||
rule = schedule.rule_json or {}
|
||||
after = clock.as_utc(schedule.next_fire_at) if schedule.next_fire_at else now
|
||||
fire_now, following = rule_service.advance(
|
||||
rule,
|
||||
# A microsecond earlier, because `next_after` answers *strictly* after
|
||||
# what it is given -- so handing it the stored due moment would return
|
||||
# the one following and skip the firing that is actually owed. The
|
||||
# alternative, making `next_after` inclusive, would break the far more
|
||||
# common "give me the one after this one" call it exists for.
|
||||
after=after - timedelta(microseconds=1),
|
||||
now=now,
|
||||
zone=zone,
|
||||
fired=schedule.fired_count or 0,
|
||||
)
|
||||
if fire_now:
|
||||
schedule.fired_count = (schedule.fired_count or 0) + 1
|
||||
schedule.last_fire_at = now
|
||||
schedule.next_fire_at = following
|
||||
if following is None:
|
||||
# Nothing left to do: a spent count, a closed window, a calendar that
|
||||
# matches nothing inside the horizon. Switched off rather than left
|
||||
# enabled with a null next time, which would read as "waiting" for ever.
|
||||
schedule.enabled = False
|
||||
return fire_now, following
|
||||
|
||||
|
||||
async def sweep(*, now: datetime | None = None) -> int:
|
||||
"""One pass. Returns how many schedules were fired.
|
||||
|
||||
Claims every due row and commits, then starts the firings — in that order,
|
||||
and with the commit in between, which is the property `test_schedule_ticker`
|
||||
checks by making a firing raise.
|
||||
"""
|
||||
now = now or datetime.now(tz=UTC)
|
||||
to_fire: list[tuple[str, datetime]] = []
|
||||
|
||||
async with _SWEEPING:
|
||||
with session_scope() as db:
|
||||
if not settings_store.schedules(db).get("enabled"):
|
||||
return 0
|
||||
limit = int(settings_store.schedules(db).get("max_concurrent") or 3)
|
||||
for schedule in db.scalars(_due(now)):
|
||||
try:
|
||||
owner = db.get(User, schedule.user_id)
|
||||
if owner is None:
|
||||
# The account is gone; the CASCADE will take the row.
|
||||
schedule.enabled = False
|
||||
continue
|
||||
due_at = clock.as_utc(schedule.next_fire_at) if schedule.next_fire_at else now
|
||||
fire_now, _ = claim(schedule, now=now, zone=clock.zone_for(owner))
|
||||
if fire_now:
|
||||
to_fire.append((schedule.id, due_at))
|
||||
except Exception: # noqa: BLE001 - one bad row must not stop the sweep
|
||||
log.exception("could not claim schedule %s", schedule.id)
|
||||
with contextlib.suppress(Exception):
|
||||
schedule.enabled = False
|
||||
schedule.last_error = "This schedule could not be read, so it was stopped."
|
||||
# Committed before a single firing starts. This is the claim.
|
||||
db.commit()
|
||||
|
||||
if not to_fire:
|
||||
return 0
|
||||
|
||||
semaphore = asyncio.Semaphore(max(1, limit))
|
||||
|
||||
async def _guarded(schedule_id: str, due_at: datetime) -> None:
|
||||
async with semaphore:
|
||||
await runner.fire(schedule_id, due_at=due_at)
|
||||
|
||||
for schedule_id, due_at in to_fire:
|
||||
task = asyncio.create_task(_guarded(schedule_id, due_at))
|
||||
_FIRING.add(task)
|
||||
task.add_done_callback(_FIRING.discard)
|
||||
return len(to_fire)
|
||||
|
||||
|
||||
def _interval() -> float:
|
||||
with contextlib.suppress(Exception), session_scope() as db:
|
||||
return float(settings_store.schedules(db).get("tick_seconds") or TICK_SECONDS)
|
||||
return TICK_SECONDS
|
||||
|
||||
|
||||
async def _loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(_interval())
|
||||
await sweep()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - the ticker must outlive one bad sweep
|
||||
log.exception("the schedule ticker raised")
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Begin ticking, once. Idempotent, so a second call in one process is not a
|
||||
second ticker firing everything twice."""
|
||||
global _TICKER
|
||||
if _TICKER is None or _TICKER.done():
|
||||
_TICKER = asyncio.create_task(_loop())
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
global _TICKER
|
||||
if _TICKER is not None:
|
||||
_TICKER.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await _TICKER
|
||||
_TICKER = None
|
||||
for task in list(_FIRING):
|
||||
task.cancel()
|
||||
for task in list(_FIRING):
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
_FIRING.clear()
|
||||
|
||||
|
||||
def release_claims() -> int:
|
||||
"""Clear `claimed_at` on rows whose firing did not survive the last run.
|
||||
|
||||
A restart abandons a reply in flight -- that is already true of every
|
||||
generation here -- so a schedule whose firing was interrupted would
|
||||
otherwise carry a claim stamp for ever and read as permanently running.
|
||||
"""
|
||||
with session_scope() as db:
|
||||
stuck = list(db.scalars(select(Schedule).where(Schedule.claimed_at.is_not(None))))
|
||||
for schedule in stuck:
|
||||
schedule.claimed_at = None
|
||||
schedule.last_error = "This run was interrupted by a restart."
|
||||
if stuck:
|
||||
db.commit()
|
||||
return len(stuck)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Making, changing and stopping a schedule.
|
||||
|
||||
The row-level half: what the routes and the tools both need, so neither has its
|
||||
own idea of what creating a schedule involves. `services/schedule/` holds the
|
||||
machinery — when it next comes due, and what happens when it does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
KIND_TASK,
|
||||
ORIGIN_USER,
|
||||
ORIGINS,
|
||||
TARGET_CHAT,
|
||||
TARGETS,
|
||||
Chat,
|
||||
Schedule,
|
||||
User,
|
||||
)
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_TITLE_CHARS = 200
|
||||
MAX_INSTRUCTION_CHARS = 8000
|
||||
|
||||
|
||||
class ScheduleError(Exception):
|
||||
"""Something a person needs told, in words they can act on."""
|
||||
|
||||
|
||||
def visible(user: User | None):
|
||||
if user is None:
|
||||
return select(Schedule).where(Schedule.id.is_(None))
|
||||
return select(Schedule).where(Schedule.user_id == user.id)
|
||||
|
||||
|
||||
def get(db: DBSession, schedule_id: str, user: User | None) -> Schedule | None:
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if schedule is None or user is None or schedule.user_id != user.id:
|
||||
return None
|
||||
return schedule
|
||||
|
||||
|
||||
def for_chat(db: DBSession, chat: Chat) -> Schedule | None:
|
||||
"""The schedule a task chat belongs to, if any."""
|
||||
return db.scalars(select(Schedule).where(Schedule.chat_id == chat.id)).first()
|
||||
|
||||
|
||||
def count_for(db: DBSession, user: User) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count()).select_from(Schedule).where(Schedule.user_id == user.id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def create(
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
title: str,
|
||||
instruction: str,
|
||||
rule: dict,
|
||||
request: str = "",
|
||||
target: str = TARGET_CHAT,
|
||||
model_id: str = "",
|
||||
origin: str = ORIGIN_USER,
|
||||
) -> Schedule:
|
||||
"""Write a schedule and the chat it fires into.
|
||||
|
||||
**The chat is created here, with the schedule**, and this is the one place
|
||||
"chats are created lazily" is deliberately bent. That rule exists so an
|
||||
opened-and-abandoned chat never appears in the sidebar; a task chat is not
|
||||
opened and abandoned, because creating it *is* the act. It also has to exist
|
||||
before the first firing, which may be days away and will have nobody
|
||||
present to make one.
|
||||
|
||||
A task chat is never `KIND_AGENT`. Scheduling one would mean running
|
||||
commands on a timer with nobody watching — and since every mode except Auto
|
||||
stalls waiting for an approval that will not come, the only two outcomes are
|
||||
"unattended execution" and "does nothing". That deserves its own pass with a
|
||||
mode built for it, not a flag here.
|
||||
"""
|
||||
clean = rule_service.validate(rule)
|
||||
if not clean:
|
||||
raise ScheduleError(
|
||||
"That does not describe a time anything could run at. "
|
||||
"Say when it should happen — a date and time, or how often."
|
||||
)
|
||||
if rule_service.next_after(clean, datetime.now(tz=UTC), zone=clock.zone_for(owner)) is None:
|
||||
# Belt and braces over `validate`'s own invariant. A schedule that can
|
||||
# never fire looks exactly like a working one on every screen it appears
|
||||
# on, so it is refused at the only moment somebody is present to be told.
|
||||
raise ScheduleError("That schedule has no next run — its time has already passed.")
|
||||
|
||||
limit = int(settings_store.schedules(db).get("max_per_user") or 20)
|
||||
if count_for(db, owner) >= limit:
|
||||
raise ScheduleError(
|
||||
f"You already have {limit} schedules, which is the most this instance allows. "
|
||||
"Remove one before adding another."
|
||||
)
|
||||
|
||||
chat = Chat(
|
||||
user_id=owner.id,
|
||||
kind=KIND_TASK,
|
||||
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
|
||||
model_id=model_id or "",
|
||||
)
|
||||
db.add(chat)
|
||||
db.flush()
|
||||
|
||||
schedule = Schedule(
|
||||
user_id=owner.id,
|
||||
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
|
||||
request=(request or "").strip()[:MAX_INSTRUCTION_CHARS],
|
||||
instruction=(instruction or "").strip()[:MAX_INSTRUCTION_CHARS],
|
||||
rule_json=clean,
|
||||
target=target if target in TARGETS else TARGET_CHAT,
|
||||
chat_id=chat.id,
|
||||
model_id=model_id or "",
|
||||
origin=origin if origin in ORIGINS else ORIGIN_USER,
|
||||
enabled=True,
|
||||
next_fire_at=rule_service.next_after(
|
||||
clean, datetime.now(tz=UTC), zone=clock.zone_for(owner)
|
||||
),
|
||||
compiled_at=datetime.now(tz=UTC),
|
||||
)
|
||||
db.add(schedule)
|
||||
db.commit()
|
||||
return schedule
|
||||
|
||||
|
||||
def update(
|
||||
db: DBSession,
|
||||
schedule: Schedule,
|
||||
*,
|
||||
owner: User,
|
||||
title: str | None = None,
|
||||
instruction: str | None = None,
|
||||
rule: dict | None = None,
|
||||
target: str | None = None,
|
||||
) -> Schedule:
|
||||
"""Change a schedule. Absent arguments are left alone."""
|
||||
if title is not None and title.strip():
|
||||
schedule.title = title.strip()[:MAX_TITLE_CHARS]
|
||||
if instruction is not None:
|
||||
schedule.instruction = instruction.strip()[:MAX_INSTRUCTION_CHARS]
|
||||
if target is not None and target in TARGETS:
|
||||
schedule.target = target
|
||||
if rule is not None:
|
||||
clean = rule_service.validate(rule)
|
||||
if not clean:
|
||||
raise ScheduleError(
|
||||
"That does not describe a time anything could run at. "
|
||||
"Say when it should happen — a date and time, or how often."
|
||||
)
|
||||
schedule.rule_json = clean
|
||||
# Recomputed from now, and the count restarted: an edited schedule is a
|
||||
# new intention, and carrying the old `fired_count` into a new `count`
|
||||
# would silently spend most of it before the first run.
|
||||
schedule.fired_count = 0
|
||||
schedule.next_fire_at = rule_service.next_after(
|
||||
clean, datetime.now(tz=UTC), zone=clock.zone_for(owner)
|
||||
)
|
||||
if schedule.next_fire_at is None:
|
||||
raise ScheduleError("That schedule has no next run — its time has already passed.")
|
||||
db.commit()
|
||||
return schedule
|
||||
|
||||
|
||||
def set_enabled(db: DBSession, schedule: Schedule, *, owner: User, enabled: bool) -> Schedule:
|
||||
"""Pause or resume.
|
||||
|
||||
**Resuming recomputes from now**, never from the stored value. A schedule
|
||||
paused for a month would otherwise come back due — and with catching-up in
|
||||
the sweep, it would fire the moment it was switched on, having decided it
|
||||
was owed a run from four weeks ago.
|
||||
"""
|
||||
schedule.enabled = bool(enabled)
|
||||
if enabled:
|
||||
schedule.last_error = ""
|
||||
schedule.next_fire_at = rule_service.next_after(
|
||||
schedule.rule_json or {},
|
||||
datetime.now(tz=UTC),
|
||||
zone=clock.zone_for(owner),
|
||||
fired=schedule.fired_count or 0,
|
||||
)
|
||||
if schedule.next_fire_at is None:
|
||||
schedule.enabled = False
|
||||
schedule.last_error = "There are no runs left in this schedule."
|
||||
db.commit()
|
||||
return schedule
|
||||
|
||||
|
||||
def delete(db: DBSession, schedule: Schedule, *, keep_chat: bool = True) -> None:
|
||||
"""Remove a schedule, and by default keep its transcript.
|
||||
|
||||
Keeping is the default because deleting a conversation as a side effect of
|
||||
removing a timer is exactly the destructive default this codebase avoids
|
||||
elsewhere. The chat becomes an ordinary one so it is reachable again — a
|
||||
`KIND_TASK` chat with no schedule behind it would be in no list at all.
|
||||
"""
|
||||
chat = db.get(Chat, schedule.chat_id) if schedule.chat_id else None
|
||||
if chat is not None:
|
||||
if keep_chat:
|
||||
chat.kind = "chat"
|
||||
else:
|
||||
db.delete(chat)
|
||||
db.delete(schedule)
|
||||
db.commit()
|
||||
|
||||
|
||||
def describe(schedule: Schedule, *, owner: User | None) -> str:
|
||||
return rule_service.describe(schedule.rule_json or {}, zone=clock.zone_for(owner))
|
||||
@@ -30,6 +30,7 @@ SEARCH = "search"
|
||||
PROMPTS = "prompts"
|
||||
AGENTS = "agents"
|
||||
IMAGES = "images"
|
||||
SCHEDULES = "schedules"
|
||||
|
||||
|
||||
def _general_defaults() -> dict[str, Any]:
|
||||
@@ -272,6 +273,36 @@ def _images_defaults() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _schedules_defaults() -> dict[str, Any]:
|
||||
"""Scheduling: work that happens because time passed rather than because
|
||||
somebody asked just now.
|
||||
|
||||
Off until an administrator turns it on, for the reason agent execution is:
|
||||
this spends model time — and, in an agent chat, runs commands — with nobody
|
||||
at the keyboard, which is a capability somebody chooses on purpose rather
|
||||
than one that arrives with an upgrade.
|
||||
"""
|
||||
return {
|
||||
"enabled": False,
|
||||
# How often the ticker looks. The rule's own granularity is a minute, so
|
||||
# this bounds how late a firing can be; 30s costs one indexed SELECT.
|
||||
"tick_seconds": 30,
|
||||
# A ceiling per person, so one account cannot fill the ticker's sweep.
|
||||
"max_per_user": 20,
|
||||
# Firings running at once. Fifty schedules due at 09:00 must not open
|
||||
# fifty generations against one endpoint.
|
||||
"max_concurrent": 3,
|
||||
# The floor `rule.validate` clamps an interval up to. Separate from the
|
||||
# rule module's own hard minimum: an administrator may want a coarser
|
||||
# floor than "a minute" without editing code.
|
||||
"min_interval_seconds": 60,
|
||||
# How many turns may pile up unanswered in one chat before a firing is
|
||||
# skipped instead of queued. `_drain` takes one per reply, so an
|
||||
# unbounded queue is a backlog that outlives the day that caused it.
|
||||
"max_queued": 3,
|
||||
}
|
||||
|
||||
|
||||
_DEFAULTS: dict[str, Any] = {
|
||||
GENERAL: _general_defaults,
|
||||
AUDIO: _audio_defaults,
|
||||
@@ -279,6 +310,7 @@ _DEFAULTS: dict[str, Any] = {
|
||||
PROMPTS: _prompts_defaults,
|
||||
AGENTS: _agents_defaults,
|
||||
IMAGES: _images_defaults,
|
||||
SCHEDULES: _schedules_defaults,
|
||||
}
|
||||
|
||||
|
||||
@@ -411,6 +443,26 @@ def images(db: DBSession) -> dict[str, Any]:
|
||||
return values
|
||||
|
||||
|
||||
def schedules(db: DBSession) -> dict[str, Any]:
|
||||
"""Scheduling settings, with the numbers clamped on read.
|
||||
|
||||
Clamped here rather than at the save, for the reason `agents` gives: a value
|
||||
stored by an earlier version cannot bite either. Every floor below is a
|
||||
number that means something bad at zero -- a tick of 0 is a busy loop, a
|
||||
concurrency of 0 is a ticker that claims firings and never runs them, and
|
||||
both would look from the outside like scheduling simply not working.
|
||||
"""
|
||||
values = get_group(db, SCHEDULES)
|
||||
values["tick_seconds"] = min(max(int(values.get("tick_seconds") or 30), 5), 300)
|
||||
values["max_per_user"] = min(max(int(values.get("max_per_user") or 20), 1), 200)
|
||||
values["max_concurrent"] = min(max(int(values.get("max_concurrent") or 3), 1), 20)
|
||||
values["min_interval_seconds"] = min(
|
||||
max(int(values.get("min_interval_seconds") or 60), 60), 86400
|
||||
)
|
||||
values["max_queued"] = min(max(int(values.get("max_queued") or 3), 1), 50)
|
||||
return values
|
||||
|
||||
|
||||
def images_ready(db: DBSession) -> bool:
|
||||
"""Whether image generation can actually happen.
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ LABELS: dict[str, str] = {
|
||||
"notes_edit": "Note updated",
|
||||
"notes_delete": "Note deleted",
|
||||
"scratch_write": "Canvas written",
|
||||
# Reports.
|
||||
"report_write": "Report filed",
|
||||
"report_search": "Reports searched",
|
||||
"report_get": "Report read",
|
||||
"image_generate": "Image",
|
||||
"memory_add": "Memory saved",
|
||||
"memory_forget": "Memory removed",
|
||||
@@ -95,6 +99,9 @@ ICONS: dict[str, str] = {
|
||||
"notes_edit": "pencil",
|
||||
"notes_delete": "trash",
|
||||
"scratch_write": "file-text",
|
||||
"report_write": "pencil",
|
||||
"report_search": "search",
|
||||
"report_get": "file-text",
|
||||
"image_generate": "image",
|
||||
"memory_add": "star",
|
||||
"memory_forget": "trash",
|
||||
@@ -133,6 +140,9 @@ ACTIONS: dict[str, str] = {
|
||||
"notes_edit": "Change a note",
|
||||
"notes_delete": "Delete a note",
|
||||
"scratch_write": "Write in the canvas",
|
||||
"report_write": "File a report",
|
||||
"report_search": "Search reports",
|
||||
"report_get": "Read a report",
|
||||
"image_generate": "Generate an image",
|
||||
"memory_add": "Remember something",
|
||||
"memory_forget": "Forget something",
|
||||
@@ -155,6 +165,12 @@ DETAIL_KEYS: dict[str, str] = {
|
||||
"web_search": "query",
|
||||
"knowledge_search": "query",
|
||||
"notes_search": "query",
|
||||
"report_search": "query",
|
||||
# The title, not the body: a card has room for a line and the body is the
|
||||
# report. Editable for the same reason the image prompt is -- correcting
|
||||
# what a report will be called before it is filed is cheap, and renaming
|
||||
# one afterwards means finding it first.
|
||||
"report_write": "title",
|
||||
"job_stop": "id",
|
||||
# The thing being agreed to is what will be drawn, not which sampler draws
|
||||
# it. Also what makes the box on the card editable: a prompt corrected
|
||||
|
||||
@@ -32,9 +32,10 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, Chat, User
|
||||
from lembas.db.models import AUTHOR_MODEL, KIND_TASK, SOURCE_CHAT, Chat, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import scratch as scratch_service
|
||||
from lembas.services import search as search_service
|
||||
from lembas.services import settings_store
|
||||
@@ -119,6 +120,16 @@ FAMILY_AGENT = "agent"
|
||||
# the whole cost of this one is somewhere else.
|
||||
FAMILY_IMAGE = "image"
|
||||
|
||||
# Filing a finished piece of work where the reader will find it later.
|
||||
# Deliberately not part of `notes`, and the line is the one a note already
|
||||
# draws from the other side: a note is something to be found again *by the
|
||||
# model*, searched for mid-conversation and edited when it turns out to be
|
||||
# wrong. A report is addressed to a person, read once, and never answered --
|
||||
# so it is the destination for work nobody was watching, which is exactly what
|
||||
# a note is not. Narrowing notes off must not take it away, and turning it on
|
||||
# must not hand out the notebook.
|
||||
FAMILY_REPORT = "report"
|
||||
|
||||
# The built-in families, in the order they are offered.
|
||||
FAMILIES = (
|
||||
FAMILY_SEARCH,
|
||||
@@ -130,6 +141,7 @@ FAMILIES = (
|
||||
FAMILY_SCRATCH,
|
||||
FAMILY_ASK,
|
||||
FAMILY_IMAGE,
|
||||
FAMILY_REPORT,
|
||||
FAMILY_AGENT,
|
||||
)
|
||||
|
||||
@@ -670,6 +682,93 @@ async def _run_memory_forget(context: ToolContext, args: dict[str, Any]) -> Tool
|
||||
)
|
||||
|
||||
|
||||
# --- Reports -----------------------------------------------------------------
|
||||
async def _run_report_write(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
title = str(args.get("title") or "").strip()
|
||||
body = str(args.get("body") or "").strip()
|
||||
summary = str(args.get("summary") or "").strip()
|
||||
if not body:
|
||||
return ToolOutcome(
|
||||
"A report needs a body. Write what you found, not a note saying you found it.",
|
||||
{"name": "report_write", "status": "error", "error": "Empty body."},
|
||||
)
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
if user is None:
|
||||
return ToolOutcome(
|
||||
"That report could not be filed.",
|
||||
{"name": "report_write", "status": "error", "error": "No such owner."},
|
||||
)
|
||||
report = reports_service.create(
|
||||
db,
|
||||
owner=user,
|
||||
title=title,
|
||||
body=body,
|
||||
summary=summary,
|
||||
source=SOURCE_CHAT,
|
||||
source_id=context.chat_id or "",
|
||||
model_id=context.model_id or "",
|
||||
)
|
||||
return ToolOutcome(
|
||||
f"Filed report {report.id} — {report.title!r}. "
|
||||
"The reader will find it under Reports; they cannot reply to it there.",
|
||||
{
|
||||
"name": "report_write",
|
||||
"query": report.title,
|
||||
"status": "ok",
|
||||
"results": [{"title": report.title, "id": report.id}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _run_report_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
query = str(args.get("query") or "").strip()
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
found = (
|
||||
reports_service.search(db, user, query, limit=8)
|
||||
if query
|
||||
else reports_service.recent(db, user, limit=8)
|
||||
)
|
||||
event = {
|
||||
"name": "report_search",
|
||||
"query": query,
|
||||
"status": "ok",
|
||||
"results": [{"title": r.title, "id": r.id} for r in found],
|
||||
}
|
||||
if not found:
|
||||
return ToolOutcome("There are no reports matching that.", event)
|
||||
lines = ["Reports:"]
|
||||
for report in found:
|
||||
when = report.created_at.strftime("%Y-%m-%d %H:%M")
|
||||
lines.append(
|
||||
f"\n[{report.id}] {when} — {report.title}\n{reports_service.snippet(report)}"
|
||||
)
|
||||
lines.append("\nUse report_get with an id to read one in full.")
|
||||
return ToolOutcome("\n".join(lines), event)
|
||||
|
||||
|
||||
async def _run_report_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, context.owner_id)
|
||||
report = reports_service.get(db, str(args.get("id") or ""), user)
|
||||
if report is None:
|
||||
return ToolOutcome(
|
||||
"There is no such report.",
|
||||
{"name": "report_get", "status": "error", "error": "Not found."},
|
||||
)
|
||||
when = report.created_at.strftime("%Y-%m-%d %H:%M")
|
||||
return ToolOutcome(
|
||||
f"{report.title}\nFiled {when}\n\n{report.body}",
|
||||
{
|
||||
"name": "report_get",
|
||||
"query": report.title,
|
||||
"status": "ok",
|
||||
"results": [{"title": report.title, "id": report.id}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- Skills ------------------------------------------------------------------
|
||||
async def _run_skill_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
name = str(args.get("name") or "").strip()
|
||||
@@ -971,6 +1070,73 @@ REGISTRY: dict[str, ToolDef] = {
|
||||
run=_run_memory_forget,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="report_write",
|
||||
family=FAMILY_REPORT,
|
||||
description=(
|
||||
"File a report: a finished piece of work, written for the person "
|
||||
"to read later. Use this when you have been asked for one, and "
|
||||
"when you finish a long piece of work whose result is worth "
|
||||
"keeping — an investigation, a summary of what you found, an "
|
||||
"account of what you changed. A report is read on its own, away "
|
||||
"from this conversation and possibly long after it, and THE "
|
||||
"READER CANNOT REPLY TO IT. So write it whole: say what you were "
|
||||
"asked, what you found and what you conclude, and do not refer "
|
||||
"to 'the above' or ask a question at the end."
|
||||
),
|
||||
parameters=_object(
|
||||
{
|
||||
"title": {
|
||||
**_STRING,
|
||||
"description": (
|
||||
"One line naming what this is about, as it will appear "
|
||||
"in a list of dozens. 'Build failures this week', not "
|
||||
"'Report' or 'Results'."
|
||||
),
|
||||
},
|
||||
"body": {
|
||||
**_STRING,
|
||||
"description": (
|
||||
"The report itself, in Markdown. Headings and lists are "
|
||||
"rendered. This is the whole of what the reader gets, so "
|
||||
"it should stand on its own with no further context."
|
||||
),
|
||||
},
|
||||
"summary": {
|
||||
**_STRING,
|
||||
"description": (
|
||||
"One sentence for the list page, so the report can be "
|
||||
"triaged without opening it. Say the finding, not the "
|
||||
"subject: 'Three tests fail on ARM only', not 'About the "
|
||||
"test failures'. Omit it and the first line of the body "
|
||||
"is used instead."
|
||||
),
|
||||
},
|
||||
},
|
||||
["title", "body"],
|
||||
),
|
||||
run=_run_report_write,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="report_search",
|
||||
family=FAMILY_REPORT,
|
||||
description=(
|
||||
"Search reports filed earlier, yours and the reader's. With no "
|
||||
"query, returns the most recent. Worth doing before writing a "
|
||||
"recurring report, so this week's can say what changed since last "
|
||||
"week's rather than repeating it."
|
||||
),
|
||||
parameters=_object({"query": _STRING}, []),
|
||||
run=_run_report_search,
|
||||
),
|
||||
ToolDef(
|
||||
name="report_get",
|
||||
family=FAMILY_REPORT,
|
||||
description="Read one report in full, by the id a search returned.",
|
||||
parameters=_object({"id": _STRING}, ["id"]),
|
||||
run=_run_report_get,
|
||||
),
|
||||
ToolDef(
|
||||
name="skill_get",
|
||||
family=FAMILY_SKILLS,
|
||||
@@ -1153,12 +1319,23 @@ def _family_allowed(
|
||||
# the shape `resolve_tools` already refuses for `skill_get` with an
|
||||
# empty library. `settings_store.images_ready` answers all three.
|
||||
return bool(allowed.get("tools.image") and images)
|
||||
if gate in (FAMILY_CUSTOM, FAMILY_MCP, FAMILY_ASK, FAMILY_AGENT, FAMILY_SCRATCH):
|
||||
if gate in (
|
||||
FAMILY_CUSTOM,
|
||||
FAMILY_MCP,
|
||||
FAMILY_ASK,
|
||||
FAMILY_AGENT,
|
||||
FAMILY_SCRATCH,
|
||||
FAMILY_REPORT,
|
||||
):
|
||||
# Deliberately without `library.use`: an HTTP endpoint an administrator
|
||||
# wrote has nothing to do with this person's own documents and notes,
|
||||
# and requiring the library permission for it would be a coincidence of
|
||||
# naming rather than a rule. The same goes for being asked a question,
|
||||
# and for a pad that belongs to this chat and goes nowhere else.
|
||||
# for a pad that belongs to this chat and goes nowhere else, and for
|
||||
# filing a report -- which is addressed to the reader rather than kept
|
||||
# for the model, and is the fallback destination for scheduled work, so
|
||||
# gating it behind the library would switch that off for anyone whose
|
||||
# instance does not use one.
|
||||
return bool(allowed.get(f"tools.{gate}"))
|
||||
return bool(allowed.get(f"tools.{gate}") and allowed.get("library.use"))
|
||||
|
||||
@@ -1293,6 +1470,16 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
off = scoped_off(chat)
|
||||
empty_library = not skills_service.count_enabled(db, user, exclude=scoped_skills_off(chat))
|
||||
|
||||
# A scheduled task runs with nobody present, so `ask_user` cannot work here:
|
||||
# it pauses the reply and waits for a POST that will never come, until
|
||||
# `approval_timeout` expires -- a run that silently does nothing for fifteen
|
||||
# minutes and then gives up. Withdrawn from the offered set rather than
|
||||
# merely discouraged in `core.unattended`, because a rule living only in a
|
||||
# system message is one a page the model just read can argue with. The
|
||||
# fragment is the half that stops it *planning* around a tool it has not got.
|
||||
if chat is not None and chat.kind == KIND_TASK:
|
||||
off = off | {FAMILY_ASK}
|
||||
|
||||
return ToolSet(
|
||||
tuple(
|
||||
tool
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Starting a reply when nobody asked for one just now.
|
||||
|
||||
Two things need this: a background job finishing on the far side, and a schedule
|
||||
coming due. Both are the same problem — put a turn into a chat from outside any
|
||||
request, and get it answered — and both depend on the same invariant, which is
|
||||
subtle enough that having two copies of it is how one of them drifts.
|
||||
|
||||
**The invariant.** A chat may have one generation running at a time. So the
|
||||
check and the writes happen under a per-chat lock with **no `await` between
|
||||
them**: two things waking one chat at the same moment cannot each spin up a
|
||||
reply, because the second sees the first's already live and leaves its turn
|
||||
`queued` for that reply's `_inject`/`_drain` to deliver.
|
||||
|
||||
**The role is load-bearing.** The turn is written as `user`, because `_inject`
|
||||
sends a queued turn verbatim and `build_messages` must keep seeing a user turn
|
||||
where one belongs. What stops the transcript claiming the reader typed it is
|
||||
`Message.machine`, which changes the bubble and nothing about the request. The
|
||||
framing the *model* reads therefore has to live in the words -- which is why
|
||||
every caller passes prose that names itself, the way `execute_plan` quotes a
|
||||
plan rather than asserting it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def lock_for(chat_id: str) -> asyncio.Lock:
|
||||
"""The lock for one chat, made on first use.
|
||||
|
||||
Not cleaned up: a lock is two pointers, chats are finite, and a reaper for
|
||||
these would be able to delete one while a waiter held it.
|
||||
"""
|
||||
existing = _LOCKS.get(chat_id)
|
||||
if existing is None:
|
||||
existing = _LOCKS[chat_id] = asyncio.Lock()
|
||||
return existing
|
||||
|
||||
|
||||
async def wake_chat(chat_id: str, content: str, *, model_id: str = "") -> str:
|
||||
"""Put a turn into a chat and get it answered. Returns the assistant message
|
||||
id if a reply was started, or "" if the turn was queued for one already
|
||||
running.
|
||||
|
||||
Never raises: a caller here is a watcher or a ticker, and one chat that
|
||||
cannot be woken must not stop the others.
|
||||
|
||||
`model_id` overrides the chat's own, which is what lets a schedule name the
|
||||
model it wants without editing the chat. Empty means the chat decides.
|
||||
"""
|
||||
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
assistant_id = ""
|
||||
async with lock_for(chat_id):
|
||||
# Read before the writes and with nothing awaited in between. This is
|
||||
# the whole invariant; moving either half out of the lock, or awaiting
|
||||
# anything between them, reintroduces two concurrent replies to one
|
||||
# chat -- which is a Stop button pointing at whichever bubble happens to
|
||||
# come first in the document.
|
||||
running = generation_service.running_for(chat_id) is not None
|
||||
try:
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None:
|
||||
return ""
|
||||
chat_service.create_message(
|
||||
db, chat, ROLE_USER, content, queued=running, machine=True
|
||||
)
|
||||
if not running:
|
||||
assistant = chat_service.create_message(
|
||||
db,
|
||||
chat,
|
||||
ROLE_ASSISTANT,
|
||||
"",
|
||||
complete_=False,
|
||||
model_id=model_id or chat.model_id,
|
||||
)
|
||||
assistant_id = assistant.id
|
||||
except Exception: # noqa: BLE001 - a failed wake must not kill its caller
|
||||
log.exception("could not wake chat %s", chat_id)
|
||||
return ""
|
||||
|
||||
# Outside the lock: `ensure` spawns a task that will want a session of its
|
||||
# own, and holding this while it starts would serialise every wake on the
|
||||
# instance behind the slowest one.
|
||||
if assistant_id:
|
||||
generation_service.ensure(chat_id, assistant_id)
|
||||
return assistant_id
|
||||
|
||||
|
||||
def queued_count(chat_id: str) -> int:
|
||||
"""How many turns are waiting to be delivered into this chat.
|
||||
|
||||
`_drain` takes **one** per reply, deliberately -- two consecutive user turns
|
||||
in a request is worse than a delay. So something firing repeatedly into a
|
||||
chat that is answering slowly can build a backlog nobody asked for, and the
|
||||
callers here use this to stop rather than to queue for ever.
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.db.models import Message
|
||||
from lembas.db.session import session_scope
|
||||
|
||||
with session_scope() as db:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(Message.chat_id == chat_id, Message.queued.is_(True))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user