CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped
Step 1 made placement cheap for a task whose status CHANGES: create_task and update_task return where it sits, and the report is written from that. It did nothing for a task a reply merely cites. This milestone's own step-6 review reported "#4014 is the open step of milestone 409". #4014 had been done for four days; the open step was #4015. The id did not come from a read — it came from a retrieval hint, which carries an id, a kind and a title and says nothing about status, while list_milestones said "8 of 9" and would not say which one. The gap was there to be filled and the nearest-looking id filled it. Two surfaces, one principle: the status arrives with the id. 1. get_project_milestone_summaries gains next_step — the earliest open step, {id, title, status} or None — carried through _BRIEF_FIELDS to enter_project, get_project and list_milestones. One extra flat query for the whole batch, so #2384's fan-out does not come back. OPEN_STEP_STATUSES moves to services/milestones.py and placement.py imports it; both surfaces now answer "what is next" and must not drift on what counts as open. Both step queries take the same readable_notes_clause (rule 78), so a row cannot name a step its own progress numbers exclude. 2. _record_kind renders a task's status: [task (done)], [issue (todo)]. A finished step and an open one read identically before, which is exactly the line the misreport was taken from. Only tasks — is_task IS status-is-not-None on the model, so there is no fallback branch. reporting-back gains the practice, owned and registered in the guidance ownership table: a record you only mention is a record to read. The guards are structural and each fails on the regression it names: the query count is asserted rather than the payload shape, and the two surfaces' agreement is pinned on the rendered ORDER BY, since a mocked session hands back whatever order the test chose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
410 lines
17 KiB
Python
410 lines
17 KiB
Python
"""Milestone management service."""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.milestone import Milestone
|
|
from scribe.models.note import Note
|
|
from scribe.services import access as access_svc
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The statuses that make a step OPEN work, defined here because this is the
|
|
# lower layer: services/placement.py imports it rather than restating it. Two
|
|
# surfaces that both answer "what is next" and disagree about what counts as
|
|
# next is worse than one of them staying silent.
|
|
OPEN_STEP_STATUSES = ("todo", "in_progress")
|
|
|
|
|
|
def embed_milestone(milestone: Milestone) -> None:
|
|
"""Refresh a milestone's vectors, fire-and-forget (milestone 415).
|
|
|
|
At the service, so every path that writes a milestone gets it — the lesson
|
|
embed_note records (#2056): a record written through a door that forgot the
|
|
call stays out of search until a restart. Exceptions are swallowed because
|
|
a milestone that saved must not fail on its index refresh; no running loop
|
|
(a script, a unit test) is ordinary. A delete racing the refresh wins: the
|
|
upsert claims the milestone's row first (#3262).
|
|
"""
|
|
try:
|
|
import asyncio
|
|
|
|
from scribe.services.embeddings import upsert_milestone_embedding
|
|
|
|
asyncio.create_task(upsert_milestone_embedding(
|
|
milestone.id, milestone.title, milestone.description, milestone.body,
|
|
))
|
|
except RuntimeError:
|
|
pass
|
|
except Exception: # noqa: BLE001 - never let indexing break a write
|
|
logger.exception("embedding refresh failed for milestone %s", milestone.id)
|
|
|
|
|
|
async def create_milestone(
|
|
user_id: int,
|
|
project_id: int,
|
|
title: str,
|
|
description: str | None = None,
|
|
body: str | None = None,
|
|
order_index: int = 0,
|
|
status: str = "active",
|
|
) -> Milestone:
|
|
async with async_session() as session:
|
|
milestone = Milestone(
|
|
user_id=user_id,
|
|
project_id=project_id,
|
|
title=title,
|
|
description=description,
|
|
body=body,
|
|
status=status,
|
|
order_index=order_index,
|
|
)
|
|
session.add(milestone)
|
|
await session.commit()
|
|
await session.refresh(milestone)
|
|
embed_milestone(milestone)
|
|
return milestone
|
|
|
|
|
|
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Milestone).where(
|
|
Milestone.id == milestone_id, Milestone.user_id == user_id,
|
|
Milestone.deleted_at.is_(None),
|
|
)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def titles_for(milestone_ids: set[int]) -> dict[int, str]:
|
|
"""{id: title} for the given milestones, for rows that name where a record sits.
|
|
|
|
No ownership filter, deliberately: the callers are listings whose rows the
|
|
caller could already read, and a milestone title is part of "where does
|
|
this record sit". Filtering here would blank the placement of a shared
|
|
task in someone else's plan while still showing the task.
|
|
"""
|
|
ids = {i for i in milestone_ids if i}
|
|
if not ids:
|
|
return {}
|
|
async with async_session() as session:
|
|
rows = (await session.execute(
|
|
select(Milestone.id, Milestone.title).where(
|
|
Milestone.id.in_(ids), Milestone.deleted_at.is_(None),
|
|
)
|
|
)).all()
|
|
return {mid: title for mid, title in rows}
|
|
|
|
|
|
async def get_milestone_in_project(project_id: int, milestone_id: int) -> Milestone | None:
|
|
"""Fetch a milestone by id within a project, without a user_id ownership check.
|
|
Callers must verify project access separately before using this."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Milestone).where(
|
|
Milestone.id == milestone_id,
|
|
Milestone.project_id == project_id,
|
|
Milestone.deleted_at.is_(None),
|
|
)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Milestone).where(
|
|
Milestone.user_id == user_id,
|
|
Milestone.project_id == project_id,
|
|
func.lower(Milestone.title) == func.lower(title.strip()),
|
|
).limit(1)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def find_milestone_by_title(user_id: int, title: str) -> Milestone | None:
|
|
"""Find a milestone by title across ALL projects for this user (case-insensitive)."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Milestone).where(
|
|
Milestone.user_id == user_id,
|
|
func.lower(Milestone.title) == func.lower(title.strip()),
|
|
).order_by(Milestone.id).limit(1)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
|
|
milestone = await get_milestone_by_title(user_id, project_id, title)
|
|
if milestone:
|
|
return milestone
|
|
return await create_milestone(user_id, project_id, title=title)
|
|
|
|
|
|
async def list_milestones(
|
|
user_id: int, project_id: int, status: str | None = None
|
|
) -> list[Milestone]:
|
|
async with async_session() as session:
|
|
query = select(Milestone).where(
|
|
Milestone.user_id == user_id,
|
|
Milestone.project_id == project_id,
|
|
Milestone.deleted_at.is_(None),
|
|
)
|
|
if status:
|
|
query = query.where(Milestone.status == status)
|
|
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
|
|
result = await session.execute(query)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Milestone).where(
|
|
Milestone.id == milestone_id, Milestone.user_id == user_id,
|
|
Milestone.deleted_at.is_(None),
|
|
)
|
|
)
|
|
milestone = result.scalars().first()
|
|
if milestone is None:
|
|
return None
|
|
for key, value in fields.items():
|
|
if hasattr(milestone, key):
|
|
setattr(milestone, key, value)
|
|
milestone.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(milestone)
|
|
if {"title", "description", "body"} & set(fields):
|
|
embed_milestone(milestone)
|
|
return milestone
|
|
|
|
|
|
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
|
|
)
|
|
milestone = result.scalars().first()
|
|
if milestone is None:
|
|
return False
|
|
await session.delete(milestone)
|
|
await session.commit()
|
|
return True
|
|
|
|
|
|
async def get_milestone_progress(milestone_id: int) -> dict:
|
|
"""Return task completion stats for a milestone."""
|
|
async with async_session() as session:
|
|
rows = await session.execute(
|
|
select(Note.status, func.count(Note.id))
|
|
.where(
|
|
Note.milestone_id == milestone_id, Note.status.isnot(None),
|
|
Note.deleted_at.is_(None),
|
|
)
|
|
.group_by(Note.status)
|
|
)
|
|
status_counts: dict[str, int] = {}
|
|
for status, count in rows.fetchall():
|
|
status_counts[status] = count
|
|
|
|
# Same rule as the batch path, computed in one place so the two cannot
|
|
# drift on the cancelled-exclusion.
|
|
return _progress_from_counts(status_counts)
|
|
|
|
|
|
def _progress_from_counts(status_counts: dict[str, int]) -> dict:
|
|
"""The progress shape, computed from already-fetched counts.
|
|
|
|
Split out of get_milestone_progress so the batch path can reuse the rule
|
|
rather than restate it — the cancelled-exclusion below is easy to get
|
|
subtly different in a second copy, and then two screens disagree about
|
|
whether a milestone is finished.
|
|
"""
|
|
total = sum(status_counts.values())
|
|
cancelled = status_counts.get("cancelled", 0)
|
|
completed = status_counts.get("done", 0)
|
|
# Cancelled tasks are resolved work, not pending — excluded from the
|
|
# denominator so a milestone whose only open task was cancelled reaches
|
|
# 100% instead of stalling.
|
|
active_total = total - cancelled
|
|
return {
|
|
"total": total,
|
|
"completed": completed,
|
|
"pct": round(completed / active_total * 100, 1) if active_total > 0 else 0.0,
|
|
"status_counts": {
|
|
"todo": status_counts.get("todo", 0),
|
|
"in_progress": status_counts.get("in_progress", 0),
|
|
"done": status_counts.get("done", 0),
|
|
"cancelled": cancelled,
|
|
},
|
|
}
|
|
|
|
|
|
async def get_project_milestone_summaries(
|
|
user_id: int, project_ids: list[int]
|
|
) -> dict[int, list[dict]]:
|
|
"""Milestone summaries for MANY projects in three queries total.
|
|
|
|
The per-project version below is a nested fan-out: one query to list a
|
|
project's milestones, then one more per milestone for its progress. Called
|
|
for 25 projects concurrently it asked for ~250 pooled connections against a
|
|
pool of 15, and every one of them waited out the 30-second checkout timeout
|
|
(#2384). This does the same work in a fixed number of queries and one
|
|
session: the milestones, their step counts, and their open steps.
|
|
|
|
Each row carries `next_step` — the earliest open step, or None (#4154).
|
|
That is NOT the same question `services/placement.py` answers: placement
|
|
knows which step you are on and names the next one AFTER it, while a
|
|
listing has no current step, so the earliest open one is the whole answer.
|
|
The two share `OPEN_STEP_STATUSES` and the creation ordering so they can
|
|
never disagree about which steps are candidates.
|
|
"""
|
|
if not project_ids:
|
|
return {}
|
|
|
|
async with async_session() as session:
|
|
milestones = list((await session.execute(
|
|
select(Milestone).where(
|
|
Milestone.user_id == user_id,
|
|
Milestone.project_id.in_(project_ids),
|
|
Milestone.deleted_at.is_(None),
|
|
).order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
|
|
)).scalars().all())
|
|
|
|
counts: dict[int, dict[str, int]] = {}
|
|
step_touched: dict[int, datetime] = {}
|
|
next_step: dict[int, dict] = {}
|
|
if milestones:
|
|
milestone_ids = [m.id for m in milestones]
|
|
# Both step queries below take the SAME visibility clause (rule 78).
|
|
# They have to: `next_step` names a step and the counts beside it
|
|
# say how many there are, so a row that could name a step its own
|
|
# progress excludes would be reporting two different milestones.
|
|
readable = access_svc.readable_notes_clause(user_id)
|
|
rows = await session.execute(
|
|
select(
|
|
Note.milestone_id, Note.status, func.count(Note.id),
|
|
func.max(Note.updated_at),
|
|
)
|
|
.where(
|
|
Note.milestone_id.in_(milestone_ids),
|
|
Note.status.isnot(None),
|
|
Note.deleted_at.is_(None),
|
|
readable,
|
|
)
|
|
.group_by(Note.milestone_id, Note.status)
|
|
)
|
|
for milestone_id, status, count, latest in rows.fetchall():
|
|
counts.setdefault(milestone_id, {})[status] = count
|
|
if latest and (milestone_id not in step_touched
|
|
or latest > step_touched[milestone_id]):
|
|
step_touched[milestone_id] = latest
|
|
|
|
# ONE query for every milestone in the batch, not one per row.
|
|
# #2384 was exactly this listing fanned out per milestone, and it
|
|
# drained the connection pool; a third flat query keeps the cost
|
|
# constant in the number of plans. Ordered the way
|
|
# services/placement.py orders steps — creation order, the order a
|
|
# plan is written and a batch create inserts — so the first row per
|
|
# milestone IS its next open step.
|
|
open_rows = await session.execute(
|
|
select(Note.milestone_id, Note.id, Note.title, Note.status)
|
|
.where(
|
|
Note.milestone_id.in_(milestone_ids),
|
|
Note.status.in_(OPEN_STEP_STATUSES),
|
|
Note.deleted_at.is_(None),
|
|
readable,
|
|
)
|
|
.order_by(Note.created_at.asc(), Note.id.asc())
|
|
)
|
|
for milestone_id, note_id, title, status in open_rows.fetchall():
|
|
next_step.setdefault(
|
|
milestone_id, {"id": note_id, "title": title, "status": status},
|
|
)
|
|
|
|
out: dict[int, list[dict]] = {pid: [] for pid in project_ids}
|
|
for m in milestones:
|
|
entry = m.to_dict()
|
|
entry.update(_progress_from_counts(counts.get(m.id, {})))
|
|
# A milestone's own updated_at doesn't move when its steps do, so a
|
|
# plan whose steps closed today would read as untouched since it was
|
|
# written (#4045). Touched is the later of the two.
|
|
touched = [t for t in (m.updated_at, step_touched.get(m.id)) if t]
|
|
entry["last_touched_at"] = max(touched).isoformat() if touched else None
|
|
# Always present, None included. "8 of 9 done" tells a reader there is
|
|
# an open step and not WHICH, and a gap that shape gets filled from
|
|
# whatever id is nearest to hand — a retrieval hint carries an id and a
|
|
# title and no status, and that is how a done step was reported as the
|
|
# open one (#4154). A listing that names it leaves nothing to guess.
|
|
entry["next_step"] = next_step.get(m.id)
|
|
out.setdefault(m.project_id, []).append(entry)
|
|
return out
|
|
|
|
|
|
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
|
|
"""Ordered milestones with progress — the one-project view of
|
|
get_project_milestone_summaries (two queries, not N+1)."""
|
|
return (await get_project_milestone_summaries(user_id, [project_id])).get(project_id, [])
|
|
|
|
|
|
# What a milestone LISTING needs: enough to say what each plan is, how far
|
|
# along it is, and which step is next. The plan itself (`body`) is
|
|
# get_milestone's job. Summaries once carried it, and on a project with 39
|
|
# milestones enter_project came to ~222k characters, 168k of them plan bodies. That is past what an MCP client will
|
|
# accept as a tool result, so the session handshake arrived as a file to page
|
|
# through (#4045). user_id / project_id / timestamps repeat what the caller
|
|
# already knows.
|
|
_BRIEF_FIELDS = (
|
|
"id", "title", "description", "status", "order_index",
|
|
"total", "completed", "pct", "status_counts", "next_step",
|
|
)
|
|
|
|
|
|
def brief_milestone_summary(
|
|
rows: list[dict], *, limit: int | None = None,
|
|
) -> tuple[list[dict], int]:
|
|
"""Trim summary rows to the listing fields, optionally keeping only the
|
|
most recently touched.
|
|
|
|
With `limit`, keeps the N rows with the latest `last_touched_at`, whatever
|
|
their status, most recent first: a handshake says what was worked on
|
|
lately, and "active" alone doesn't (a plan can sit active for months).
|
|
Without it, every row stays in its original order. Returns (rows, omitted).
|
|
"""
|
|
kept = rows
|
|
if limit is not None:
|
|
kept = sorted(
|
|
rows, key=lambda r: r.get("last_touched_at") or "", reverse=True,
|
|
)[:limit]
|
|
brief = [{k: r[k] for k in _BRIEF_FIELDS if k in r} for r in kept]
|
|
return brief, len(rows) - len(kept)
|
|
|
|
|
|
def unplanned_milestones(
|
|
rows: list[dict], *, exclude_ids: set[int] = frozenset(), limit: int | None = None,
|
|
) -> tuple[list[dict], int]:
|
|
"""Active milestones with no steps yet, as (rows, omitted).
|
|
|
|
A plan written as a milestone with a description and no steps is open work
|
|
that nothing else names. It is never "touched" — touching is a step
|
|
changing — so the recency list that brief_milestone_summary(limit=) builds
|
|
can never reach it, and progress reads 0% either way. A project whose
|
|
roadmap was written that way ended up with every later plan opened as a
|
|
new milestone beside the one that already described it (milestone 415).
|
|
|
|
`exclude_ids` drops milestones a caller already listed. Kept in roadmap
|
|
order (order_index, then creation), the order they were written in.
|
|
Rows are id, title and description: what a reader needs to recognise the
|
|
plan, and not its body, which get_milestone reads.
|
|
"""
|
|
found = [
|
|
{"id": r["id"], "title": r.get("title"), "description": r.get("description")}
|
|
for r in rows
|
|
if r.get("status") == "active" and not r.get("total") and r["id"] not in exclude_ids
|
|
]
|
|
kept = found if limit is None else found[:limit]
|
|
return kept, len(found) - len(kept)
|