diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 7ddbbc7..6d56659 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -250,6 +250,7 @@ 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 + old_accept = task.accept_state # Мандат (ТЗ 3.20): без флага доступности агент задачу не меняет, а сам флаг # ставит только владелец — иначе «пометил и закрыл» обходило бы правило _mandate(actor, task, sets_eligible="ai_eligible" in data) @@ -361,6 +362,9 @@ # События после коммита: подписчик SSE может сразу перечитать данные db.commit() publish(user_id, "task.changed", {"id": task.id}) + if task.accept_state == "pending" and old_accept != "pending": + # Работа агента ждёт приёмки (ТЗ 3.20): владельцу — пометка и тост + publish(user_id, "task.review", {"id": task.id, "title": task.title}) if earned_event is not None: publish( user_id, @@ -388,6 +392,7 @@ # владельца событиями — а в задаче ничего не изменилось if fresh: publish(user_id, "task.changed", {"id": task.id}) + publish(user_id, "task.claimed", {"id": task.id, "claimed_by": task.claimed_by}) return task diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 5ebb4e3..9a01ac3 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -509,6 +509,7 @@ ) old_status = task.status old_eligible = task.ai_eligible + old_accept = task.accept_state # Мандат (ТЗ 3.20): без флага доступности агент задачу не меняет, а сам флаг # ставит только владелец; занятую чужим агентом задачу агент не трогает — # иначе он закрыл бы работу того, кто её уже делает. Владельцу рамка не мешает @@ -625,6 +626,9 @@ claims.clear_claim(task) session.commit() publish(user_id, "task.changed", {"id": task.id, "source": "mcp"}) + if task.accept_state == "pending" and old_accept != "pending": + # Работа агента ждёт приёмки (ТЗ 3.20): владельцу — пометка и тост + publish(user_id, "task.review", {"id": task.id, "title": task.title}) if outcome is not None and outcome.event is not None: # Скромный тост в открытых вкладках — закрытие тоже праздник (ТЗ 3.14) publish( @@ -856,6 +860,14 @@ if fresh: tasklog.log_event(session, task, tasklog.KIND_CLAIMED, actor) session.commit() + if fresh: + # Продление аренды — не новость для владельца, а взятие — новость (ТЗ 3.20) + publish(user_id, "task.changed", {"id": task.id, "source": "mcp"}) + publish( + user_id, + "task.claimed", + {"id": task.id, "claimed_by": task.claimed_by}, + ) result = _available(task, actor) result["renewed"] = not fresh return result diff --git a/extension/src/api.ts b/extension/src/api.ts index 491182a..dc8da61 100644 --- a/extension/src/api.ts +++ b/extension/src/api.ts @@ -49,6 +49,10 @@ headers: { 'Content-Type': 'application/json', ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + // Актор действия (ТЗ 3.20): расширение — руки владельца. Без заголовка + // Bearer-запрос считается действием ИИ-агента, и захваты из расширения + // попадали бы в журнал как агентские + 'X-Actor': 'user', ...(init.headers ?? {}), }, }) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 635803b..c4d2013 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -24,6 +24,7 @@ { id: 'options', label: t('nav.options'), icon: 'ph-shuffle', to: '/options' }, { id: 'list', label: t('nav.list'), icon: 'ph-list-bullets', to: '/list' }, { id: 'projects', label: t('nav.projects'), icon: 'ph-folders', to: '/projects' }, + { id: 'journal', label: t('nav.journal'), icon: 'ph-clipboard-text', to: '/journal' }, { id: 'achievements', label: t('nav.achievements'), icon: 'ph-trophy', to: '/achievements' }, { id: 'stats', label: t('nav.stats'), icon: 'ph-chart-bar', to: '/stats' }, // архив — редко используемый раздел, предпоследний пункт diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 44caddd..483b469 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -73,6 +73,18 @@ recur_day_of_month: number | null /** «Ментально сложная» (ТЗ 3.13): метка «не хочется делать», +10 XP при закрытии */ mentally_hard: boolean + /** Мандат ИИ-агента и приёмка (ТЗ 3.20) */ + ai_eligible: boolean + /** Кто создал задачу: user | agent; имя владельца или label токена — в created_by_name */ + created_by_kind: string + created_by_name: string | null + /** Кто закрыл последним: user | agent; null — до 0.85 неизвестно */ + done_by_kind: string | null + /** Плоскость приёмки: null | pending | accepted | rejected */ + accept_state: string | null + /** Подпись арендатора — только пока аренда жива (иначе null) */ + claimed_by: string | null + claim_expires_at: string | null created_at: string approved_at: string | null done_at: string | null @@ -98,8 +110,38 @@ recur_weekdays?: string | null recur_day_of_month?: number | null mentally_hard?: boolean + /** Доступность ИИ-агенту (ТЗ 3.20): ставит владелец — и в форме, и правкой */ + ai_eligible?: boolean /** Не поле задачи: источник закрытия для геймификации ("options" — режим «3 вариантов») */ earned_via?: string + /** Не поле задачи: комментарий к закрытию (ТЗ 3.20) — уходит в журнал */ + close_comment?: string +} + +/** Запись журнала действий по задаче (ТЗ 3.20) */ +export interface TaskEvent { + id: number + /** null — задача удалена; запись живёт снапшотом заголовка */ + task_id: number | null + task_title: string + project_id: number | null + /** created | eligible_changed | claimed | released | completed | accepted | rejected | status_changed | deleted */ + kind: string + /** user | agent */ + actor_kind: string + actor_name: string + /** ui | api | mcp */ + via: string + comment: string | null + created_at: string +} + +/** Страница журнала — единственный пагинированный список API (ТЗ 3.20) */ +export interface TaskEventPage { + items: TaskEvent[] + total: number + page: number + per_page: number } export interface XpSummary { @@ -298,6 +340,30 @@ body: JSON.stringify({ available_minutes: availableMinutes }), }), deleteTask: (id: number) => request<{ ok: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }), + // Мандат ИИ-агента (ТЗ 3.20): взятие в работу и приёмка + claimTask: (id: number) => request(`/api/tasks/${id}/claim`, { method: 'POST' }), + releaseTask: (id: number, reason = '') => + request(`/api/tasks/${id}/release?` + new URLSearchParams({ reason }), { + method: 'POST', + }), + acceptTask: (id: number, comment = '') => + request(`/api/tasks/${id}/accept`, { + method: 'POST', + body: JSON.stringify({ comment: comment || null }), + }), + rejectTask: (id: number, comment = '') => + request(`/api/tasks/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ comment: comment || null }), + }), + // Журнал действий: первый пагинированный список API (ТЗ 3.20) + listTaskEvents: (params: Record = {}) => { + const qs = new URLSearchParams() + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)) + }) + return request('/api/task-events?' + qs) + }, // README.md репозитория, если описание задачи — только ссылка на git getRepoReadme: (url: string) => request<{ content: string }>('/api/repo-readme?' + new URLSearchParams({ url })), diff --git a/frontend/src/components/GamifyBridge.vue b/frontend/src/components/GamifyBridge.vue index 8f2e538..851f2c3 100644 --- a/frontend/src/components/GamifyBridge.vue +++ b/frontend/src/components/GamifyBridge.vue @@ -5,7 +5,13 @@ import { onMounted } from 'vue' import { useToast } from 'gnexus-ui-kit/vue' import { api } from '../api' -import { initGamification, notifyDaily, notifyRemoteXp } from '../gamification' +import { + initGamification, + notifyDaily, + notifyRemoteXp, + notifyTaskClaimed, + notifyTaskReview, +} from '../gamification' import { onRealtime } from '../realtime' initGamification(useToast()) @@ -17,6 +23,11 @@ if (e.data?.celebrate) notifyRemoteXp(amount) }) +// Мандат ИИ-агента (ТЗ 3.20): агент ходит своим токеном в другой сессии, и без +// этих тостов о его работе владелец узнаёт только заглянув в список +onRealtime('task.claimed', (e) => notifyTaskClaimed(String(e.data?.claimed_by ?? ''))) +onRealtime('task.review', (e) => notifyTaskReview(String(e.data?.title ?? ''))) + // Дейли-награда за визит (ТЗ 3.13): при каждой загрузке приложения спрашиваем // бэк — он начисляет 50 монет только раз в сутки, повторные входы молчат onMounted(async () => { diff --git a/frontend/src/components/TaskBadges.vue b/frontend/src/components/TaskBadges.vue index 86b75ac..36b2a89 100644 --- a/frontend/src/components/TaskBadges.vue +++ b/frontend/src/components/TaskBadges.vue @@ -34,6 +34,38 @@