"""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