diff --git a/backend/app/api/task_events.py b/backend/app/api/task_events.py new file mode 100644 index 0000000..c5d9a39 --- /dev/null +++ b/backend/app/api/task_events.py @@ -0,0 +1,57 @@ +"""Чтение журнала действий по задачам (ТЗ 3.20). + +Первый пагинированный список в API: конверт `{items, total, page, per_page}`. +Потолок `per_page` — 100: журнал растёт быстро, отдавать его целиком нельзя. +""" + +from fastapi import APIRouter, Query +from sqlalchemy import func, select + +from app.dependencies import DbDep, UserIdDep +from app.models import TaskEvent +from app.schemas import TaskEventOut, TaskEventPage + +router = APIRouter(prefix="/api/task-events", tags=["task-events"]) + +MAX_PER_PAGE = 100 + + +@router.get("", response_model=TaskEventPage) +async def list_task_events( + db: DbDep, + user_id: UserIdDep, + kind: str | None = Query(None, description="Тип события (created, completed, …)"), + actor_kind: str | None = Query(None, pattern="^(user|agent)$"), + via: str | None = Query(None, pattern="^(ui|api|mcp)$"), + project_id: int | None = Query(None), + task_id: int | None = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=MAX_PER_PAGE), +) -> TaskEventPage: + """Журнал пользователя, свежие записи первыми.""" + filters = [TaskEvent.user_id == user_id] + if kind: + filters.append(TaskEvent.kind == kind) + if actor_kind: + filters.append(TaskEvent.actor_kind == actor_kind) + if via: + filters.append(TaskEvent.via == via) + if project_id: + filters.append(TaskEvent.project_id == project_id) + if task_id: + filters.append(TaskEvent.task_id == task_id) + total = db.scalar(select(func.count()).select_from(TaskEvent).where(*filters)) or 0 + rows = db.scalars( + select(TaskEvent) + .where(*filters) + # id вторым ключом: у записей одной секунды иначе нет устойчивого порядка + .order_by(TaskEvent.created_at.desc(), TaskEvent.id.desc()) + .limit(per_page) + .offset((page - 1) * per_page) + ).all() + return TaskEventPage( + items=[TaskEventOut.model_validate(row) for row in rows], + total=total, + page=page, + per_page=per_page, + ) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 0288e28..dd40e18 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response from sqlalchemy import Select, case, or_, select -from app.dependencies import DbDep, UserIdDep +from app.dependencies import ActorDep, DbDep, UserIdDep from app.models import CoinEvent, Project, Tag, Task, utcnow from app.realtime import publish from app.schemas import ( @@ -17,6 +17,7 @@ TaskOut, TaskUpdate, ) +from app.services import tasklog from app.services.closing import handle_task_closed from app.services.detailing import apply_proposal, detail_task from app.services.options import pick_options @@ -90,6 +91,7 @@ schema: TaskCreate, db: DbDep, user_id: UserIdDep, + actor: ActorDep, background: BackgroundTasks, response: Response, ) -> dict[str, int]: @@ -104,9 +106,15 @@ title=schema.title, description=schema.description, parent_task_id=schema.parent_task_id, + # Доступность агенту (ТЗ 3.20) и автор задачи: «создано агентом» — признак + # самой задачи, иначе список фильтровался бы join'ом с токенами + ai_eligible=schema.ai_eligible, + created_by_kind=actor.kind, + created_by_name=actor.name or None, ) db.add(task) db.flush() + tasklog.log_event(db, task, tasklog.KIND_CREATED, actor) # Микронаграда за создание (ТЗ 3.13): без конфетти, просто тост — # заголовки X-Created-* фронт буферизует отдельно от награды за закрытие grant_create_xp(db, "create_task", user_id) @@ -201,10 +209,16 @@ @router.patch("/{task_id}", response_model=TaskOut) async def update_task( - task_id: int, schema: TaskUpdate, db: DbDep, user_id: UserIdDep, response: Response + task_id: int, + schema: TaskUpdate, + db: DbDep, + user_id: UserIdDep, + actor: ActorDep, + response: Response, ) -> Task: task = _get_task_for_update(db, task_id, user_id) data = schema.model_dump(exclude_unset=True) + old_status, old_eligible = task.status, task.ai_eligible if "title" in data and not str(data["title"]).strip(): raise HTTPException(status_code=422, detail="title cannot be empty") @@ -238,6 +252,8 @@ task.tags = list(tags) # Пометка источника закрытия (геймификация): не поле задачи, в setattr не идёт earned_via = data.pop("earned_via", None) + # Комментарий к закрытию (ТЗ 3.20): уходит в журнал, агенту обязателен (7) + close_comment = data.pop("close_comment", None) earned_event = None for field, value in data.items(): @@ -264,6 +280,25 @@ # даст, а спавн удержит spawned_at — дубликатов не будет task.done_at = None + # Журнал (ТЗ 3.20): в него идёт всё жизненное — закрытие, смена статуса, + # выдача мандата. Записи только о настоящих переменах: PATCH, ничего не + # изменивший, журнал не засоряет + if data.get("status") and task.status != old_status: + if task.status == "done" and old_status != "done": + tasklog.log_event(db, task, tasklog.KIND_COMPLETED, actor, close_comment) + else: + tasklog.log_event( + db, + task, + tasklog.KIND_STATUS, + actor, + tasklog.status_transition(old_status, task.status), + ) + if "ai_eligible" in data and task.ai_eligible != old_eligible: + tasklog.log_event( + db, task, tasklog.KIND_ELIGIBLE, actor, "on" if task.ai_eligible else "off" + ) + db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления # События после коммита: подписчик SSE может сразу перечитать данные @@ -330,8 +365,13 @@ @router.delete("/{task_id}") -async def delete_task(task_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]: - db.delete(_get_task_or_404(db, task_id, user_id)) +async def delete_task( + task_id: int, db: DbDep, user_id: UserIdDep, actor: ActorDep +) -> dict[str, bool]: + task = _get_task_or_404(db, task_id, user_id) + # Журнал переживает задачу: запись делает снапшот заголовка (ТЗ 3.20) + tasklog.log_deleted(db, task, actor) + db.delete(task) db.commit() publish(user_id, "task.deleted", {"id": task_id}) return {"ok": True} diff --git a/backend/app/main.py b/backend/app/main.py index 5c1f897..e88b963 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from app import mcp_server from app.actor import VIA_MCP, agent_actor -from app.api import attachments, events, garden, projects, tags, tasks, xp +from app.api import attachments, events, garden, projects, tags, task_events, tasks, xp from app.api.mcp_tokens import router as mcp_tokens_router from app.api.repo_readme import router as repo_readme_router from app.api.settings import router as settings_router @@ -89,6 +89,7 @@ app.include_router(auth_router) app.include_router(auth_webhooks_router) app.include_router(tasks.router) +app.include_router(task_events.router) app.include_router(projects.router) app.include_router(tags.router) app.include_router(attachments.router) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 35f168b..0b85f5a 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -19,6 +19,7 @@ from pydantic import Field from sqlalchemy import and_, func, select +from app.actor import Actor, actor_from_request from app.config import get_settings from app.db import get_session_factory from app.models import Attachment, CoinEvent, Document, Project, Tag, Task, utcnow @@ -30,6 +31,7 @@ normalize_repository_url, normalize_site_url, ) +from app.services import tasklog from app.services.closing import handle_task_closed from app.services.detailing import detail_task, summarize_project from app.services.xp import coins_for_create, grant_create_xp @@ -48,6 +50,16 @@ return str(user_id) +def _mcp_actor(ctx: Context[Any, Any, Any] | None, is_user: bool = False) -> Actor: + """Актор действия: по умолчанию агент, is_user=True — «от имени владельца» (ТЗ 3.20). + + Заголовок `X-Actor` здесь не читается: у MCP своё объявление — аргумент тула. + """ + request = ctx.request_context.request if ctx is not None and ctx.request_context else None + actor = actor_from_request(request) + return actor.declaring_user() if is_user else actor + + # Серверные инструкции — агент видит их до вызова любого тула (ТЗ 3.10). # Дисциплина проектного контекста: задачи не должны отрываться от проекта, # а контекст разговора восстанавливается через get_project. @@ -274,15 +286,29 @@ priority: Annotated[ int | None, Field(description="Приоритет 0-10: 0-2 низкий, 5-6 средний, 9-10 срочный") ] = None, + ai_eligible: Annotated[ + bool, + Field( + description=( + "true — задача доступна ИИ-агенту (владелец разрешил её взять). " + "Ставьте true, только если владелец просил" + ) + ), + ] = False, + is_user: Annotated[ + bool, Field(description="true — действую от имени владельца, а не как агент") + ] = False, ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Создать задачу; достаточно title — остальное дописывается через update_task. По возможности привяжите задачу к проекту (project_id или project_name) — задача без проекта теряется. Автодетализация подставит метаданные в фоне. - Ответ содержит поле project — проверьте привязку. + Ответ содержит поле project — проверьте привязку. Задача, созданная агентом, + помечается в журнале и в интерфейсе (бейдж «создано агентом»). """ user_id = _mcp_user(ctx) + actor = _mcp_actor(ctx, is_user) session = get_session_factory()() try: if parent_task_id is not None: @@ -293,6 +319,10 @@ description=description, parent_task_id=parent_task_id, priority=priority, + # Доступность агенту (ТЗ 3.20): по умолчанию нет — мандат выдаёт владелец + ai_eligible=ai_eligible, + created_by_kind=actor.kind, + created_by_name=actor.name or None, ) project = _resolve_project(session, user_id, project_id, project_name) if project is not None: @@ -308,6 +338,7 @@ task.tags = tags session.add(task) session.flush() + tasklog.log_event(session, task, tasklog.KIND_CREATED, actor) # Микронаграда за создание (ТЗ 3.13) — как в HTTP-API grant_create_xp(session, "create_task", user_id) session.add( @@ -396,6 +427,13 @@ tag_ids: Annotated[ list[int] | None, Field(description="Полный новый список id тегов (из list_tags)") ] = None, + is_user: Annotated[ + bool, Field(description="true — действую от имени владельца, а не как агент") + ] = False, + comment: Annotated[ + str | None, + Field(description="Комментарий к закрытию: что сделано (обязателен агенту)"), + ] = None, ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Частично обновить задачу — передавайте только нужные поля. @@ -403,10 +441,13 @@ Регулярная задача: task_type="recurring" и правило recur_kind (interval: recur_interval_days; weekdays: recur_weekdays "1,3,5" пн=1..вс=7; monthly: recur_day_of_month). Закрытие (status="done") начисляет награды и создаёт - следующий экземпляр регулярной задачи автоматически. Ответ содержит поле - project — проверяйте, что задача осталась в нужном проекте. + следующий экземпляр регулярной задачи автоматически. Закрывая задачу, + передайте comment — что сделано: владелец увидит его в журнале и при приёмке + работы. Ответ содержит поле project — проверяйте, что задача осталась в + нужном проекте. """ user_id = _mcp_user(ctx) + actor = _mcp_actor(ctx, is_user) session = get_session_factory()() try: # Блокировка строки: параллельные закрытия агентами сериализуются, @@ -425,6 +466,7 @@ raise ValueError( f"Task {task_id} not found — find the right id with list_tasks(query=...)." ) + old_status = task.status if title is not None: if not title.strip(): raise ValueError("title cannot be empty") @@ -496,6 +538,18 @@ outcome = handle_task_closed(session, task) elif task.status != "done" and task.done_at is not None: task.done_at = None + # Журнал (ТЗ 3.20) — тот же, что у HTTP-API: кто и когда закрыл задачу + if status is not None and task.status != old_status: + if task.status == "done" and old_status != "done": + tasklog.log_event(session, task, tasklog.KIND_COMPLETED, actor, comment) + else: + tasklog.log_event( + session, + task, + tasklog.KIND_STATUS, + actor, + tasklog.status_transition(old_status, task.status), + ) session.commit() publish(user_id, "task.changed", {"id": task.id, "source": "mcp"}) if outcome is not None and outcome.event is not None: @@ -519,17 +573,32 @@ def complete_task( task_id: Annotated[int, Field(description="id задачи (из list_tasks/get_task)")], + comment: Annotated[ + str, Field(description="Что сделано по задаче (обязателен агенту)") + ] = "", actual_minutes: Annotated[ int | None, Field(description="Фактически потраченное время в минутах") ] = None, + is_user: Annotated[ + bool, Field(description="true — действую от имени владельца, а не как агент") + ] = False, ctx: Context[Any, Any, Any] | None = None, ) -> dict[str, Any]: """Завершить задачу (псевдоним update_task со status="done"). - Награды и спавн следующего экземпляра регулярной задачи выполняются - автоматически; повторный вызов дублей не создаёт. + Обязательно расскажите в comment, что сделано: агент закрывает задачу с + отчётом, иначе владельцу нечего принимать. Награды и спавн следующего + экземпляра регулярной задачи выполняются автоматически; повторный вызов + дублей не создаёт. """ - return update_task(task_id, status="done", actual_minutes=actual_minutes, ctx=ctx) + return update_task( + task_id, + status="done", + actual_minutes=actual_minutes, + comment=comment or None, + is_user=is_user, + ctx=ctx, + ) def list_tasks( diff --git a/backend/app/services/tasklog.py b/backend/app/services/tasklog.py new file mode 100644 index 0000000..cd42ec1 --- /dev/null +++ b/backend/app/services/tasklog.py @@ -0,0 +1,79 @@ +"""Журнал действий по задачам (ТЗ 3.20): кто, что и когда. + +Единственная точка записи в `task_events` — и для REST, и для MCP: иначе «кто +закрыл задачу» отвечалось бы по-разному в двух путях. Запись идёт в ту же +транзакцию, что и само действие; коммитит вызывающий. + +Заголовок хранится снапшотом: запись должна читаться после удаления задачи. +""" + +from sqlalchemy.orm import Session + +from app.actor import Actor +from app.models import Task, TaskEvent + +KIND_CREATED = "created" +KIND_ELIGIBLE = "eligible_changed" +KIND_CLAIMED = "claimed" +KIND_RELEASED = "released" +KIND_COMPLETED = "completed" +KIND_ACCEPTED = "accepted" +KIND_REJECTED = "rejected" +KIND_STATUS = "status_changed" +KIND_DELETED = "deleted" + +MAX_TITLE = 500 +MAX_NAME = 100 +MAX_COMMENT = 2000 +MAX_KIND = 24 + + +def status_transition(old: str, new: str) -> str: + """Переход статуса машинной записью («to_do→in_progress»). + + Коды, а не слова: интерфейс подпишет их своим языком, а журнал не хранит + переводов — записи должны читаться одинаково и через год. + """ + return f"{old}→{new}" + + +def _event( + task: Task, + kind: str, + actor: Actor, + comment: str | None, + *, + task_id: int | None, + project_id: int | None, +) -> TaskEvent: + return TaskEvent( + user_id=task.user_id, + task_id=task_id, + task_title=task.title[:MAX_TITLE], + project_id=project_id, + kind=kind[:MAX_KIND], + actor_kind=actor.kind, + actor_name=actor.name[:MAX_NAME], + via=actor.via, + comment=comment[:MAX_COMMENT] if comment else None, + ) + + +def log_event( + db: Session, task: Task, kind: str, actor: Actor, comment: str | None = None +) -> TaskEvent: + """Записать действие по задаче (создание, статус, взятие, закрытие, приёмка…).""" + event = _event(task, kind, actor, comment, task_id=task.id, project_id=task.project_id) + db.add(event) + return event + + +def log_deleted(db: Session, task: Task, actor: Actor) -> TaskEvent: + """Запись об удалении: задача уже уходит, ссылку не оставляем — только снапшот. + + (FK с ON DELETE SET NULL обнулил бы её сам, но в SQLite внешние ключи в + тестах не проверяются — журнал остался бы ссылаться на несуществующую задачу.) + """ + event = _event(task, KIND_DELETED, actor, None, task_id=None, project_id=task.project_id) + db.add(event) + return event diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f1f80dc..2a34d4a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -19,6 +19,7 @@ from sqlalchemy.pool import StaticPool # noqa: E402 from app import services # noqa: E402 +from app.actor import VIA_MCP, Actor, agent_actor # noqa: E402 from app.db import Base # noqa: E402 from app.dependencies import get_db, require_user # noqa: E402 from app.main import app # noqa: E402 @@ -96,16 +97,29 @@ yield TestClient(app) -def mcp_ctx(user_id: str = "1") -> Any: +def mcp_ctx(user_id: str = "1", actor: Actor | None = None) -> Any: """Стаб mcp.server.fastmcp.Context для прямых вызовов тулов в тестах. Тулы достают user_id из ctx.request_context.request.state.gntodo_user_id (его кладёт McpAuthMiddleware); стаб повторяет только этот доступ. + + actor — актор MCP-действия (ТЗ 3.20). По умолчанию его нет: без актора + действие считается владельческим, так и вызывают тулы тесты, написанные до + мандата. Агентские сценарии передают agent_actor(...) — тогда действие + агентское, а is_user=True делает его владельческим. """ + state = SimpleNamespace(gntodo_user_id=user_id) + if actor is not None: + state.gntodo_actor = actor class _Ctx: - request_context = SimpleNamespace( - request=SimpleNamespace(state=SimpleNamespace(gntodo_user_id=user_id)) - ) + request_context = SimpleNamespace(request=SimpleNamespace(state=state)) return _Ctx() + + +def agent_ctx( + user_id: str = "1", label: str = "Нави", token_id: int = 7, owner: str = "Владелец" +) -> Any: + """Контекст MCP-агента: действие считается агентским (ТЗ 3.20).""" + return mcp_ctx(user_id, agent_actor(label, token_id, owner, VIA_MCP)) diff --git a/backend/tests/test_task_events.py b/backend/tests/test_task_events.py new file mode 100644 index 0000000..75c7814 --- /dev/null +++ b/backend/tests/test_task_events.py @@ -0,0 +1,124 @@ +"""Тесты журнала действий по задачам (ТЗ 3.20).""" + +from typing import Any + +from fastapi.testclient import TestClient + +from app import mcp_server +from tests.conftest import agent_ctx, mcp_ctx # type: ignore[attr-defined] + + +def _events(client: TestClient, **params: Any) -> dict[str, Any]: + resp = client.get("/api/task-events", params=params) + assert resp.status_code == 200, resp.text + return resp.json() + + +def _create(client: TestClient, title: str = "задача") -> dict[str, Any]: + resp = client.post("/api/tasks", json={"title": title}) + assert resp.status_code == 200, resp.text + return resp.json() + + +def test_create_logs_event_from_ui(client: TestClient) -> None: + task = _create(client, "позвонить маме") + page = _events(client, kind="created") + assert page["total"] == 1 + row = page["items"][0] + assert row["task_id"] == task["id"] + assert row["task_title"] == "позвонить маме" + assert row["actor_kind"] == "user" + assert row["via"] == "ui" + + +def test_status_and_eligibility_changes_logged(client: TestClient) -> None: + task = _create(client) + client.patch(f"/api/tasks/{task['id']}", json={"status": "in_progress"}) + client.patch(f"/api/tasks/{task['id']}", json={"ai_eligible": True}) + # PATCH, ничего не изменивший, журнал не засоряет + client.patch(f"/api/tasks/{task['id']}", json={"ai_eligible": True}) + client.patch(f"/api/tasks/{task['id']}", json={"priority": 5}) + + kinds = [row["kind"] for row in _events(client)["items"]] + assert kinds == ["eligible_changed", "status_changed", "created"] + status_row = _events(client, kind="status_changed")["items"][0] + assert status_row["comment"] == "to_do→in_progress" + assert _events(client, kind="eligible_changed")["items"][0]["comment"] == "on" + + +def test_close_logs_completed_with_comment(client: TestClient) -> None: + task = _create(client) + resp = client.patch(f"/api/tasks/{task['id']}", json={"status": "done", "close_comment": "всё"}) + assert resp.status_code == 200, resp.text + row = _events(client, kind="completed")["items"][0] + assert row["comment"] == "всё" + + +def test_delete_keeps_event_with_title_snapshot(client: TestClient) -> None: + task = _create(client, "удаляемая") + assert client.delete(f"/api/tasks/{task['id']}").status_code == 200 + row = _events(client, kind="deleted")["items"][0] + # Ссылки на задачу нет — она удалена; запись живёт снапшотом заголовка + assert row["task_id"] is None + assert row["task_title"] == "удаляемая" + + +def test_events_are_scoped_to_owner(client: TestClient) -> None: + """Журнал чужого пользователя не виден (изоляция по user_id, ТЗ 1.2).""" + from app.dependencies import require_user + + _create(client) + overrides = client.app.dependency_overrides # type: ignore[attr-defined] + saved = overrides[require_user] + overrides[require_user] = lambda: {"user_id": "2", "email": "other@example.com"} + try: + assert _events(client)["total"] == 0 + finally: + overrides[require_user] = saved + assert _events(client)["total"] == 1 + + +def test_pagination_envelope_and_filters(client: TestClient) -> None: + project_id = client.post("/api/projects", json={"name": "дом"}).json()["id"] + for i in range(5): + task = _create(client, f"задача {i}") + client.patch( + f"/api/tasks/{task['id']}", json={"project_id": project_id, "status": "in_progress"} + ) + # 5 созданий + 5 смен статуса; смена проекта — не событие журнала + page = _events(client, page=2, per_page=3) + assert (page["total"], page["page"], page["per_page"]) == (10, 2, 3) + assert len(page["items"]) == 3 + assert _events(client, project_id=project_id)["total"] == 5 + # Потолок per_page: журнал растёт быстро, отдавать его целиком нельзя + assert client.get("/api/task-events", params={"per_page": 101}).status_code == 422 + assert client.get("/api/task-events", params={"page": 0}).status_code == 422 + + +def test_mcp_agent_actions_are_marked(client: TestClient) -> None: + """Действия агента отличимы от действий владельца: актор и канал.""" + ctx = agent_ctx(label="Нави") + created = mcp_server.create_task("от агента", ctx=ctx) + mcp_server.update_task(created["id"], status="in_progress", ctx=ctx) + + rows = _events(client)["items"] + assert [row["kind"] for row in rows] == ["status_changed", "created"] + assert {(row["actor_kind"], row["actor_name"], row["via"]) for row in rows} == { + ("agent", "Нави", "mcp") + } + + +def test_mcp_is_user_marks_owner(client: TestClient) -> None: + """is_user=True — действие от имени владельца, но канал остаётся mcp.""" + ctx = agent_ctx(label="Нави", owner="Хозяин") + created = mcp_server.create_task("как я", is_user=True, ctx=ctx) + row = _events(client, kind="created")["items"][0] + assert (row["actor_kind"], row["actor_name"], row["via"]) == ("user", "Хозяин", "mcp") + assert created["id"] + + +def test_mcp_close_comment_lands_in_journal(client: TestClient) -> None: + ctx = mcp_ctx() + created = mcp_server.create_task("задача", ctx=ctx) + mcp_server.complete_task(created["id"], comment="сделано", ctx=ctx) + assert _events(client, kind="completed")["items"][0]["comment"] == "сделано" diff --git a/docs/TZ.md b/docs/TZ.md index 0641b60..c29cf5f 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -528,7 +528,8 @@ - `claim_task` / `release_task` — взять задачу в работу и вернуть её, если она не по силам (0.85, 3.20); - `complete_task` — закрытие задачи; для действия от имени агента **обязателен - комментарий** о том, что сделано (0.85). + комментарий** о том, что сделано (0.85). Тот же параметр `comment` у + `update_task(status="done")` — закрытие идёт и через него. - Эргономика для агентов (в т.ч. небольших моделей): - серверные `instructions`: порядок работы, правило привязки задач к проекту, восстановление контекста через `get_project` после перерыва, запрет @@ -1411,7 +1412,9 @@ released | completed | accepted | rejected | status_changed | deleted), actor_kind (user | agent), actor_name (имя владельца или label токена), via (ui | api | mcp), comment (nullable — что сделано - или причина возврата), created_at. Единственный пагинированный + или причина возврата; у смены статуса и доступности — машинный + переход кодами: `to_do→in_progress`, `on`/`off`, их подписывает + интерфейс), created_at. Единственный пагинированный список на бэкенде: `GET /api/task-events` отвечает конвертом { items, total, page, per_page }, потолок per_page — 100