Files
FabledGrimoire/src/grimoire/services/placement.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

147 lines
6.2 KiB
Python

"""Where a task sits — its project, its milestone, its step position, what is next.
WHY THIS EXISTS
An agent reporting finished work to the operator is asked to place it: which
milestone, which step of how many, what comes next. Without those facts in
hand it reconstructs them from memory, and a reconstruction reads exactly like
the real thing while being wrong — an invented milestone title, a "next" step
that is already done. So the facts come back on the write that changes a task,
where the report is about to be written, instead of being left to recall.
THE SHAPE
{"project": {"id", "title"},
"milestone": {"id", "title", "status"},
"position": {"step": 3, "of": 6},
"progress": {"completed", "total", "pct"},
"next": {"id", "title", "status"} | None}
`milestone`, `position`, `progress` and `next` appear only for a task in a
milestone; a task with a project and no milestone gets `project` alone; a task
with neither gets no placement at all (None), which the doors omit rather than
send empty.
WHAT "STEP" AND "NEXT" MEAN
Notes carry no order column, so a milestone's steps are in CREATION order
(created_at, then id) — the order a plan's steps are written in, and the order
a batch create inserts them. Deliberately NOT get_milestone's listing order
(status, then last update): that is a display choice, and it reshuffles every
time anything is touched.
`next` is the first open step (todo or in_progress) AFTER this one; when every
later step is closed it falls back to the earliest open step before it, since
that is still the milestone's next piece of work. None when nothing else is open.
ACCESS
Siblings are read through access.readable_notes_clause, so a collaborator on a
shared task is never shown the title of a step they cannot open. Position and
progress are computed over that same readable set — one list, so the counts can
never disagree with the titles they sit beside. For an owner the readable set
is every step, and the numbers equal get_milestone's.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from grimoire.models import async_session
from grimoire.models.milestone import Milestone
from grimoire.models.note import Note
from grimoire.models.project import Project
from grimoire.services import access as access_svc
from grimoire.services.milestones import OPEN_STEP_STATUSES, _progress_from_counts
# Imported, not restated. The milestone summary names a plan's next open step
# too, and the two answers must agree about what "open" means.
_OPEN = OPEN_STEP_STATUSES
logger = logging.getLogger(__name__)
def _next_open(steps: list, current_id: int) -> dict | None:
"""The next open step after `current_id`, else the earliest open one before it."""
index = next((i for i, s in enumerate(steps) if s.id == current_id), -1)
later = [s for s in steps[index + 1:] if s.status in _OPEN]
earlier = [s for s in steps[:max(index, 0)] if s.status in _OPEN]
pick = (later or earlier or [None])[0]
if pick is None:
return None
return {"id": pick.id, "title": pick.title, "status": pick.status}
async def task_placement(user_id: int, task) -> dict | None:
"""Placement for `task` as `user_id` may see it, or None when it has none.
`task` is the Note the caller already holds — it was just written or read —
so its own row is not fetched again.
"""
project_id = getattr(task, "project_id", None)
milestone_id = getattr(task, "milestone_id", None)
if not project_id and not milestone_id:
return None
async with async_session() as session:
milestone = None
if milestone_id:
milestone = (await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.deleted_at.is_(None),
)
)).scalars().first()
project_id = project_id or (milestone.project_id if milestone else None)
project = None
if project_id and await access_svc.can_read_project(user_id, project_id):
project = await session.get(Project, project_id)
if project is not None and project.deleted_at is not None:
project = None
steps: list = []
if milestone is not None:
steps = list((await session.execute(
select(Note).where(
Note.milestone_id == milestone.id,
Note.status.isnot(None),
Note.deleted_at.is_(None),
access_svc.readable_notes_clause(user_id),
).order_by(Note.created_at.asc(), Note.id.asc())
)).scalars().all())
out: dict = {}
if project is not None:
out["project"] = {"id": project.id, "title": project.title}
# A milestone is shown only to someone who can read its project (or who
# owns it): the task being readable does not make its plan readable.
if milestone is not None and (project is not None or milestone.user_id == user_id):
counts: dict[str, int] = {}
for step in steps:
counts[step.status] = counts.get(step.status, 0) + 1
progress = _progress_from_counts(counts)
position = next((i for i, s in enumerate(steps, start=1) if s.id == task.id), None)
out["milestone"] = {"id": milestone.id, "title": milestone.title, "status": milestone.status}
out["position"] = {"step": position, "of": len(steps)}
out["progress"] = {k: progress[k] for k in ("completed", "total", "pct")}
out["next"] = _next_open(steps, task.id)
return out or None
async def attach_placement(user_id: int, data: dict, task) -> dict:
"""Add `placement` to a task payload the door is about to return.
Fail-open, like every in-band decoration: the write it rides on has
already happened, and a placement lookup that errors must not turn a
successful update into a reported failure. Omitted, never sent empty.
"""
try:
placement = await task_placement(user_id, task)
except Exception: # noqa: BLE001 - a decoration never breaks its payload
logger.warning("placement lookup failed for task %s", getattr(task, "id", None), exc_info=True)
return data
if placement:
data["placement"] = placement
return data