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
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>
204 lines
7.8 KiB
Python
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
|