Files
FabledScribe/src/scribe/services/task_logs.py
T
bvandeusenandClaude Opus 5.5 4a93c8b262
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Failing after 24s
fix(task-logs): a collaborator who may write a task may log on it (rule 78)
create_log filtered on Note.user_id == user_id, a bare owner check, so a
collaborator with write access to a shared task was told it did not exist
— and, since a log now stamps the claim, could never be seen working it.
It now asks can_write_note. Editing and deleting a log still require its
author, which is authorship rather than access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 06:48:01 -04:00

204 lines
7.8 KiB
Python

"""Task work log service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.task_log import TaskLog
from scribe.models.note import Note, TaskStatus
from scribe.services.access import can_read_note, can_write_note, readable_notes_clause
from scribe.services.task_claims import stamp_claim
logger = logging.getLogger(__name__)
_UNSET = object()
async def _refresh_task_document(session, task_id: int) -> None:
"""Re-embed the task whose work log just changed (#4251).
A task's embedded document carries its logs, so a log written and not
indexed is #4241's half-surface wearing different clothes: the entry is
readable and unfindable, and the next session rebuilds what this one ruled
out. Create, edit and delete all go through here — an edited log that keeps
matching its old wording is the stale-vector problem `upsert_note_embedding`
already refuses to leave behind for a body.
Loads the NOTE rather than synthesising one. `embed_note` reads
`title`, `body` and the OWNER's `user_id` off what it is handed, so a
stand-in row would index the logs under an empty title — throwing away the
per-chunk topical anchor that makes any of this discriminative — and file
the vectors under the wrong user.
Failure is swallowed the way `embed_note`'s own is: a log that saved must
not fail on its index refresh. The startup backfill is the backstop.
"""
from scribe.services.notes import embed_note
try:
result = await session.execute(select(Note).where(Note.id == task_id))
note = result.scalars().first()
if note is not None:
embed_note(note)
except Exception: # noqa: BLE001 - indexing never breaks a write
logger.exception("embedding refresh failed for task %s", task_id)
async def create_log(
user_id: int,
task_id: int,
content: str,
duration_minutes: int | None = None,
) -> TaskLog:
# Whoever may WRITE the task may log on it (rule #78) — a collaborator on a
# shared project included. This used to be a bare owner filter, which
# refused exactly the person a shared task exists for.
if not await can_write_note(user_id, task_id):
raise ValueError(f"Task {task_id} not found")
async with async_session() as session:
result = await session.execute(select(Note).where(Note.id == task_id))
task = result.scalars().first()
if task is None:
raise ValueError(f"Task {task_id} not found")
# Logging IS working the task, so it stamps the claim (milestone 381) —
# unless the work is over: a retrospective note on a closed task is not
# a session picking it up.
if task.status not in (TaskStatus.done.value, TaskStatus.cancelled.value):
stamp_claim(task, user_id)
log = TaskLog(
task_id=task_id,
user_id=user_id,
content=content,
duration_minutes=duration_minutes,
)
session.add(log)
await session.commit()
await session.refresh(log)
await _refresh_task_document(session, task_id)
return log
async def list_logs(user_id: int, task_id: int) -> list[TaskLog]:
async with async_session() as session:
result = await session.execute(
select(TaskLog)
.where(TaskLog.task_id == task_id, TaskLog.user_id == user_id)
.order_by(TaskLog.created_at.asc())
)
return list(result.scalars().all())
async def logs_for_task(
user_id: int, task_id: int, limit: int = 0
) -> list[TaskLog]:
"""Every work log on a task, NEWEST FIRST, whoever wrote it.
Two differences from `list_logs`, both deliberate:
1. Scoped by who may read the TASK, not by who wrote each entry. A work
log belongs to the task, and on a shared task the owner's record is
exactly what a collaborator needs — `list_logs`' `TaskLog.user_id ==
user_id` returns them an empty list, which reads as "no work has been
done" rather than "not yours". The permission question is asked of the
note through `can_read_note` (rule #78), so this is not an unscoped
read with the check left to the caller.
2. Newest first, because the question a reader asks of a work log is
"where does this actually stand", and the answer is the last entry.
`list_logs` stays ascending — the web UI renders a narrative.
`limit` of 0 means all of them. An unreadable or missing task returns []
the same way an empty one does: a work log is not a channel for proving a
record exists.
"""
if not await can_read_note(user_id, task_id):
return []
async with async_session() as session:
stmt = (
select(TaskLog)
.where(TaskLog.task_id == task_id)
.order_by(TaskLog.created_at.desc(), TaskLog.id.desc())
)
if limit > 0:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return list(result.scalars().all())
async def count_logs_for_task(user_id: int, task_id: int) -> int:
"""How many work logs a task carries. Same scoping as logs_for_task."""
if not await can_read_note(user_id, task_id):
return 0
async with async_session() as session:
result = await session.execute(
select(func.count(TaskLog.id)).where(TaskLog.task_id == task_id)
)
return int(result.scalar() or 0)
async def log_counts_for_tasks(
user_id: int, task_ids: list[int]
) -> dict[int, int]:
"""Log counts for a whole page of tasks in ONE query.
A per-row lookup would be N+1 by construction — and so would a per-row
`can_read_note` — which is the reason a list surface would otherwise keep
omitting this and leave a reader to guess which rows carry a record. So
the permission is expressed as set membership with
`readable_notes_clause` and folded into the same statement, the pattern
that function exists for.
Tasks with no logs are absent from the mapping; callers zero-fill, so
"none" reads as a count rather than a missing key.
"""
ids = [int(t) for t in task_ids if t]
if not ids:
return {}
async with async_session() as session:
result = await session.execute(
select(TaskLog.task_id, func.count(TaskLog.id))
.join(Note, Note.id == TaskLog.task_id)
.where(TaskLog.task_id.in_(ids), readable_notes_clause(user_id))
.group_by(TaskLog.task_id)
)
return {int(tid): int(n) for tid, n in result.all()}
async def update_log(
user_id: int,
log_id: int,
content: str | None = None,
duration_minutes: object = _UNSET,
) -> TaskLog | None:
async with async_session() as session:
result = await session.execute(
select(TaskLog).where(TaskLog.id == log_id, TaskLog.user_id == user_id)
)
log = result.scalars().first()
if log is None:
return None
if content is not None:
log.content = content
if duration_minutes is not _UNSET:
log.duration_minutes = duration_minutes # type: ignore[assignment]
log.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(log)
await _refresh_task_document(session, log.task_id)
return log
async def delete_log(user_id: int, log_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(TaskLog).where(TaskLog.id == log_id, TaskLog.user_id == user_id)
)
log = result.scalars().first()
if log is None:
return False
task_id = log.task_id
await session.delete(log)
await session.commit()
await _refresh_task_document(session, task_id)
return True