28 Commits

Author SHA1 Message Date
23bae9a68f Merge pull request 'perf(ui): инкрементальный рендер ленты логов (дельта вместо полного пересбора)' (#13) from feat/213d506fe05a8608 into main
Some checks failed
CI / test (push) Failing after 1m12s
CI / build-and-package (amd64, linux) (push) Failing after 1m5s
CI / build-and-package (amd64, windows) (push) Successful in 40s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/13
2026-08-22 00:10:58 +05:00
ki.sagidullin
70287140ec perf(ui): инкрементальный рендер ленты логов (дельта вместо полного пересбора)
Some checks failed
CI / test (pull_request) Failing after 1m14s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m3s
CI / build-and-package (amd64, windows) (pull_request) Successful in 28s
2026-08-21 23:59:59 +05:00
ki.sagidullin
d9d043ec8e feat(scripts): GCC_PATH из .gitea-creds для gcc в build-publish-ui.ps1
Some checks failed
CI / test (push) Failing after 1m14s
CI / build-and-package (amd64, linux) (push) Failing after 1m4s
CI / build-and-package (amd64, windows) (push) Successful in 25s
- проверка gcc перенесена после чтения creds (шаг 1a)
- GCC_PATH дописывается в PATH (абсолютный или относительно scripts/);
  без него — gcc из текущего PATH
- комментарии в шапку: GCC_PATH и уточнённый PROJECT_PATH
2026-08-21 23:12:20 +05:00
b87092aa98 Merge pull request 'feat(ui): filtr urovney logov + cap bufera 10MB' (#12) from feat/710a3625da1aa989 into main
Some checks failed
CI / test (push) Failing after 1m18s
CI / build-and-package (amd64, linux) (push) Failing after 1m2s
CI / build-and-package (amd64, windows) (push) Successful in 29s
2026-08-21 23:04:08 +05:00
ki.sagidullin
ee47785e1a perf(ui): кап буфера логов 10MB, O(1) вытеснение, дебаунс рендера
Some checks failed
CI / test (pull_request) Failing after 1m21s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m9s
CI / build-and-package (amd64, windows) (pull_request) Successful in 31s
- LogBuffer: ленивое вытеснение через start + инкрементальный total убирает
  O(n^2) копирование entries[1:] при большом числе записей
- LogPanel: лимит 10MB (maxLogBytes), перерисовка ленты коалесцируется
  (200мс) — строка не пересобирается на каждый append
- build-publish-ui.ps1: корень проекта задаётся через PROJECT_PATH в
  .gitea-creds (относительно scripts/), без него — папка рядом со скриптом
- тесты: живые записи Entries/Render, кап по байтам
2026-08-21 23:01:54 +05:00
ki.sagidullin
a762a1df80 feat(ui): фильтр уровней логов в панели «Логи» 2026-08-21 21:35:37 +05:00
ki.sagidullin
ee76f9d89f fix(ui): поле ввода композитора в центр (не за край окна)
Some checks failed
CI / test (push) Failing after 1m14s
CI / build-and-package (amd64, linux) (push) Failing after 1m4s
CI / build-and-package (amd64, windows) (push) Successful in 31s
2026-08-21 20:41:28 +05:00
ki.sagidullin
37cc29e4cd feat(ui): нативный максимум окна через Win32 (над панелью задач)
Some checks failed
CI / test (push) Failing after 1m20s
CI / build-and-package (amd64, linux) (push) Failing after 1m4s
CI / build-and-package (amd64, windows) (push) Successful in 29s
2026-08-21 20:14:43 +05:00
ki.sagidullin
180afb1336 chore(serena): актуализировать память cgo-winlibs-gcc (PATH не гарантирован) 2026-08-21 18:51:33 +05:00
0c88272bf0 Merge pull request 'feat(ui): каждая вкладка chat — независимая LLM-сессия' (#11) from feat/ui_llm_chat into main
Some checks failed
CI / test (push) Failing after 1m12s
CI / build-and-package (amd64, linux) (push) Failing after 58s
CI / build-and-package (amd64, windows) (push) Successful in 29s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/11
2026-08-21 18:44:27 +05:00
ki.sagidullin
9420b276e9 feat(ui): каждая вкладка chat — независимая LLM-сессия
Some checks failed
CI / test (pull_request) Failing after 1m20s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m2s
CI / build-and-package (amd64, windows) (pull_request) Successful in 36s
- ui.Session (UserID/Address/Title/BoundTask) + фабрики NewFreeSession/NewTaskSession и префикс ui://sess-*
- свободная вкладка без задачи: свой UserID, диалог с LLM в памяти (agent=chat, resume по sessionID)
- /start в свободной вкладке создаёт задачу с ChatID == UserID вкладки -> вкладка становится task-сессией
- desktop окно: container.AppTabs с вкладками, кнопка «Новый чат», маршрутизация Send/Ask/истории по Address вкладки
- app.FreeChat: in-memory связка uid -> opencode sessionID; роутинг свободного чата в handleIncoming
- агент chat.md + регистрация 'chat' в agents.Names
- тесты: session_test.go (фабрики/хелперы)
2026-08-21 10:33:05 +05:00
de21ec9f31 Merge pull request 'feat/project_utils' (#10) from feat/project_utils into main
Some checks failed
CI / test (push) Failing after 1m12s
CI / build-and-package (amd64, linux) (push) Failing after 59s
CI / build-and-package (amd64, windows) (push) Successful in 29s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/10
2026-08-21 00:10:21 +05:00
ki.sagidullin
5c82229a00 chore(serena): обновить память проекта (onboarding)
Some checks failed
CI / test (pull_request) Failing after 1m13s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m2s
CI / build-and-package (amd64, windows) (pull_request) Successful in 28s
2026-08-21 00:08:45 +05:00
ki.sagidullin
612446d65b chore(scripts): add build-publish-ui.ps1 2026-08-21 00:05:54 +05:00
307a2b4173 Merge pull request 'feat/ui-refactor' (#9) from feat/ui-refactor into main
Some checks failed
CI / test (push) Failing after 1m15s
CI / build-and-package (amd64, linux) (push) Failing after 1m1s
CI / build-and-package (amd64, windows) (push) Successful in 29s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/9
2026-08-21 00:00:18 +05:00
ki.sagidullin
8ba1a8aa00 refactor(ui): вынести панель «Состояние» (StatePanel) из монолита окна
Some checks failed
CI / test (pull_request) Failing after 1m17s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m2s
CI / build-and-package (amd64, windows) (pull_request) Successful in 28s
Спеца 12.13: интерфейс + Nil + чистые хелперы (StateText/TraceLine/
ActivityLine/TaskStatusText/StatusBadge/Itoa) в internal/ui/state_panel.go,
Fyne-реализация в internal/ui/desktop/state_panel.go. window.go — чистая
компоновка: все 5 панелей вынесены как контракты.
2026-08-20 23:54:27 +05:00
ki.sagidullin
5c93642cf8 refactor(ui): вынести панель «Диалог» (ChatPanel) из монолита окна
Спеца 12.12: интерфейс + Nil + FormatRole в internal/ui/chat_panel.go,
Fyne-реализация в internal/ui/desktop/chat_panel.go (транскрипт + композитор).
Окно как chat.Channel рендерит Send/Ask/историю через Append; state_panel
не трогаем.
2026-08-20 23:48:54 +05:00
ki.sagidullin
4c44956d3e refactor(ui): вынести панель «Детали задачи» (TaskDetailPanel) из монолита окна
Спеца 12.11: интерфейс + Nil + TaskDetailText в internal/ui/task_detail.go,
Fyne-реализация в internal/ui/desktop/task_detail_panel.go. selectTask
делегирует в панель; диалог и «Состояние» — отдельные панели, не трогаем.
2026-08-20 23:43:52 +05:00
ki.sagidullin
6274aad369 refactor(ui): вынести панель «Список задач» (TaskListPanel) из монолита окна
Спеца 12.10: интерфейс + Nil + TaskTitle в internal/ui/task_list_panel.go,
Fyne-реализация в internal/ui/desktop/task_list_panel.go. Состояние выбора
живёт в окне; выбранная строка подсвечивается (Select/Unselect).
2026-08-20 23:31:08 +05:00
ki.sagidullin
fdd73cab61 refactor(ui): вынести панель «Логи» (LogPanel) из монолита окна
Спеца 12.8-12.9: интерфейс + Nil в internal/ui/log_panel.go, Fyne-реализация
в internal/ui/desktop/log_panel.go, окно использует контракт вместо logsLbl.
2026-08-20 23:26:18 +05:00
60a6966a95 Merge pull request 'feat/new_ui' (#8) from feat/new_ui into main
Some checks failed
CI / test (push) Failing after 1m8s
CI / build-and-package (amd64, linux) (push) Failing after 58s
CI / build-and-package (amd64, windows) (push) Successful in 28s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/8
2026-08-20 14:50:07 +05:00
ki.sagidullin
c2272137b3 feat(ui): команды, snapshots, Fyne-окно и интеграция (--noui)
Some checks failed
CI / test (pull_request) Failing after 1m19s
CI / build-and-package (amd64, linux) (pull_request) Failing after 58s
CI / build-and-package (amd64, windows) (pull_request) Successful in 53s
Команды (спец 12.2):
- ui.Commands: действия UI → текстовые команды канала (start/cancel/skip/
  retry/continue/approve/send), единый путь через chat.Channel.
- ui.Window = chat.Channel + View; NilWindow для headless/тестов.

Snapshots (спец 12.5):
- ui.Store: чтение-модель, возвращает только копии (ListTasks/GetTask/
  GetHistory/GetTraces); ui.DBStore поверх storage.

Fyne-окно (internal/ui/desktop, build-tag cgo):
- список задач слева, сплиты рабочей области и панели «Логи»/«Состояние»,
  ввод+кнопки команд, тёмная тема, fullscreen, сохранение layout в
  Preferences, сворачивание при закрытии крестиком.
- Колбэки View через fyne.Do (спец 12.4).

Интеграция:
- app.New(..., noUI); флаг --noui; UI собирается только с cgo (ui_cgo/
  ui_noui фабрики), приложение headless без него.
- Run: окно блокирует главную горутину; «Завершить» → cancel → graceful
  shutdown (спец 12.6).
- go.mod: fyne.io/fyne/v2 v2.6.0 (direct).
2026-08-20 08:54:55 +05:00
ki.sagidullin
baf2ad7147 feat(ui): контроллер — подписчик шин и мост к Fyne-представлению
- View — интерфейс Fyne-слоя: колбэки на все доменные события + логи.
- Controller подписывается на доменную и логовую шины (через events.Hub)
  и диспатчит события в View; колбэки выполняются в горутинах Hub, поэтому
  реализация Fyne обязана обновлять виджеты через fyne.Do (спец 12.4).
- Close отписывает контроллер (сворачивание окна) без остановки Core.
- NilView — no-op для headless (--noui) и тестов.
- Юнит-тесты: доставка доменных/логовых событий, остановка после Close.
2026-08-20 08:15:42 +05:00
ki.sagidullin
66e40d384e feat(events): типизированный Hub — подписчик шины с диспетчеризацией
- Hub слушает *Bus в собственной горутине и вызывает зарегистрированные
  обработчики по типу события (On[T]); порядок сохраняется.
- Мост к UI: колбэки выполняются в горутине Hub → внутри можно переложить
  работу на поток Fyne (fyne.Do) или thread-safe binding.
- Close останавливает горутину и отписывается; Start идемпотентен.
- Юнит-тесты: доставка по типу, игнор посторонних типов, несколько
  обработчиков одного типа, остановка после Close.
2026-08-20 07:17:57 +05:00
ki.sagidullin
4957a5552e feat(events): внедрить Publisher в Core/Worker/Analyst + шины в app
- Core: setStatus публикует TaskStatusChanged на каждом переходе
  (handleStart/Cancel/Retry/Consent/Edit/Turn, runDecide).
- Worker: setStatus публикует TaskStatusChanged (running/timeout/failed/
  success, failTask), notify остаётся авто-уведомлением.
- Analyst: Decide публикует AgentActivity (stage=decide).
- app: создаёт доменную (256) и логовую (1024) шины, подключает их к
  Core/Worker/Analyst, редиректит log в обе (stderr + логовая шина).
2026-08-20 04:30:29 +05:00
ki.sagidullin
1f7ab9a67e feat(events): EventBus + domain-модель статусов
- internal/model: единый источник статусов задачи (Status, TraceStatus,
  машина переходов), без зависимости от storage.
- internal/storage: совместимый мост (type Status = model.Status,
  re-export констант) — внешний код не меняется.
- internal/events: шина событий (fan-out, блокирующий Publish с гарантией
  порядка), события задач/трейсов, отдельная логовая шина + LogWriter,
  Publisher/NilPublisher для внедрения в Core/Worker.
- docs/ui-spec.md: спецификация десктопного UI (Fyne).
2026-08-20 01:02:31 +05:00
408d137747 Merge pull request 'fix(opencode): считать реальный прогресс стрима для idle-детекции' (#7) from feat/3c8528d into main
All checks were successful
CI / test (push) Successful in 1m3s
CI / build-and-package (amd64, linux) (push) Successful in 54s
CI / build-and-package (amd64, windows) (push) Successful in 53s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/7
2026-08-19 19:12:56 +05:00
0b31e18d85 Merge pull request 'test: починить тесты на Windows' (#6) from feat/3c8528d into main
All checks were successful
CI / test (push) Successful in 35s
CI / build-and-package (amd64, linux) (push) Successful in 45s
CI / build-and-package (amd64, windows) (push) Successful in 42s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/6
2026-08-19 10:50:23 +05:00
58 changed files with 4324 additions and 181 deletions

View File

@@ -1,10 +1,12 @@
# conventions # conventions
## Стиль / кодстайл ## Стиль / кодстайл
- Стандартный Go-стиль; гофм `gofmt`/`go fmt ./...`. Документация-комментарии и - Стандартный Go-стиль; `gofmt`/`go fmt ./...`. Документация-комментарии и
package-doc на русском языке (в START-комментариях файлов и doc-комментариях). package-doc на русском языке.
- Типизация: строгие типы, интерфейсы для абстракций (Decider, LiveProber). - Типизация: строгие типы, интерфейсы для абстракций (Decider, ui.Store, events.Event, chat.Channel).
- Свой тип `config.Duration` для времени (YAML-строки "5s"/"10m"), метод `.Duration()`. - Свой тип `config.Duration` для времени (YAML-строки "5s"/"10m"), метод `.Duration()`.
- UI: uni-directional data flow — UI читает снапшоты (копии, `ui.Store`), не мутирует storage/core.
Ядро публикует события в `events.Bus`, UI подписывается `events.Hub` → колбэки в UI-поток через `fyne.Do`.
## Обработка ошибок ## Обработка ошибок
- Ошибки классифицируются по идентификаторам классов в исходниках (см. ниже). - Ошибки классифицируются по идентификаторам классов в исходниках (см. ниже).
@@ -15,9 +17,10 @@
| Блок | Коды | Где | | Блок | Коды | Где |
|---|---|---| |---|---|---|
| C | C1C4 | internal/config | | C | C1C4 | internal/config |
| A | A1A4 | internal/analyst | | A | A1A4 | internal/analyst; A (app): A1A4 в internal/app |
| M | M1M5 | internal/chat | | M | M1M5 | internal/chat |
| O | O1O4 | internal/opencode | | D | D1D5 | internal/core (ErrDecideFailed, ErrMaxTurns, ErrCommandUnknown…) |
| O | O1O5 | internal/opencode |
| S | S1S5 | internal/storage | | S | S1S5 | internal/storage |
| W | W1W5 | internal/worker | | W | W1W5 | internal/worker |
| E | E1E4 | internal/worker (репозитории) | | E | E1E4 | internal/worker (репозитории) |
@@ -25,15 +28,16 @@
| U | U1U6 | internal/update | | U | U1U6 | internal/update |
## Архитектурные конвенции ## Архитектурные конвенции
- **Composition root** — internal/app; подсистемы собираются там, лимиты Core - **Composition root** — internal/app; подсистемы собираются там. UI-окно создаётся через
(MaxTurns=15, MaxConfirmCycles=3, MaxQuestionsPerTurn=5) в core.New. `newUIWindow(store)` (build-tag: cgo → desktop.New, !cgo → nil/headless). Флаг `--noui` выключает окно.
- **Агенты** (analyst.md/dev.md/reviewer.md) — markdown-промпты, встроены через go:embed - **Слои:** model (домен) ← storage/events/UI зависят только от model; core не знает про UI.
(internal/agents/*.md + embed.go), распаковываются в config_dir. Вердикт — строгий JSON. - **Агенты** (analyst.md/dev.md/reviewer.md) — markdown-промпты, go:embed, распаковка в config_dir.
Вердикт — строгий JSON.
- Репо-клонирование/воркеры — через gitops; ветки задач `feat/<taskTag>` (см. mem:core). - Репо-клонирование/воркеры — через gitops; ветки задач `feat/<taskTag>` (см. mem:core).
- Обратная совместимость: поле `Repo` (одиночный) и `Repos` (список); EffectiveRepos/ - Обратная совместимость: поле `Repo` (одиночный) и `Repos` (список); EffectiveRepos/
SetReposFromDB/ReposJoined в storage/models.go. SetReposFromDB/ReposJoined в storage/models.go.
## Версии ## Версии
- `app.Version` — семантическая major.minor.patch (ручной инкремент: patch=фиксы, - `app.Version` — семантическая major.minor.patch (ручной инкремент: patch=фиксы,
minor=новая обратно-совместимая функциональность, major=несовместимые изменения). minor=новая обратно-совместимая функциональность, major=несовместимые изменения). Сейчас 0.2.2.
- `main.version` (ldflag) — build-идентификатор `commit-<sha7>`, отдельно от app.Version. - `main.version` (ldflag) — build-идентификатор `commit-<sha7>`, отдельно от app.Version.

View File

@@ -1,70 +1,57 @@
# core # core
Ratatoskr-go — оркестратор конвейера Ratatoskr (порт с Python на Go) в единый Ratatoskr-go — оркестратор конвейера Ratatoskr (порт с Python на Go) в единый
статический бинарь (CGO_ENABLED=0). Субагенты запускаются через внешний процесс бинарь. Субагенты (analyst/dev/reviewer) запускаются через внешний процесс
[opencode](https://opencode.ai). Взаимодействие — Telegram-бот. [opencode](https://opencode.ai) (v2 HTTP API). Интерфейсы: Telegram-бот + Fyne UI.
Linux — статический headless-бинарь (CGO_ENABLED=0); Windows — с Fyne-окном (cgo).
## Структура (модули internal/) ## Структура (модули internal/)
``` ```
cmd/ratatoskr/ точка входа, сборка бинаря; main.version и main.updateToken вшиваются ldflag'ом cmd/ratatoskr/ точка входа; флаги: -config, -version, -noui (headless). main.version/updateToken вшиваются ldflag'ом
internal/ internal/
app/ composition root/DI: App.New -> config.Load+Validate, ResolveExePaths, storage, Runner, Analyst, Core, Worker, Updater. packageOwner="kamelion", Version="0.1.0" app/ composition root/DI: App.New -> config.Load+Validate, ResolveExePaths, storage, Runner, Analyst, Core, Worker, Updater, UI. packageOwner="kamelion", Version="0.2.2"
config/ YAML+env загрузка (${VAR:-default}), defaults, validate C1-C4 config/ YAML+env загрузка (${VAR:-default}), defaults, validate C1-C4
model/ доменные типы/контракты: Status + validTransitions (IsValidTransition/IsTerminal), TraceStatus. Нижний слой — не зависит от storage/events
chat/ мультиканальный Router; telegram — long-poll канал. Коды M1-M5 chat/ мультиканальный Router; telegram — long-poll канал. Коды M1-M5
core/ state-machine задач + Decider/analyst интерфейс. Коды D3/D4 core/ state-machine задач + Decider/analyst интерфейс. Коды D3/D4
analyst/ аналитик: промпт + разбор JSON-вердикта (opencode agent). Коды A1-A4 analyst/ аналитик: промпт + разбор JSON-вердикта (opencode agent). Коды A1-A4
events/ шина событий UI: Bus (buffered Sub/Pub), типизированный Hub, события TaskCreated/TaskStatusChanged/HistoryAppended/TraceAppended/AgentActivity, LogBus (панель «Логи»)
worker/ polling-планировщик + dev/reviewer-конвейер + gitops. Коды W*, E*, R* worker/ polling-планировщик + dev/reviewer-конвейер + gitops. Коды W*, E*, R*
agents/ встроенные opencode-агенты (analyst.md, dev.md, reviewer.md) через go:embed agents/ встроенные opencode-агенты (analyst.md, dev.md, reviewer.md) через go:embed
opencode/ HTTP-клиент v2 API opencode serve, поллинг вердикта, LiveRegistry. Коды O1-O5 opencode/ HTTP-клиент v2 API opencode serve, поллинг вердикта, LiveRegistry. Коды O1-O5
storage/ SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history. Коды S1-S5 storage/ SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history. Коды S1-S5
update/ автообновление из Gitea Packages. Коды U1-U6 update/ автообновление из Gitea Packages. Коды U1-U6
ui/ desktop-оболочка: Store-абстракция (копии), Commands (UI->Core), Controller (шины->View), View/Window интерфейсы
ui/desktop/ Fyne-реализация окна (build-tag cgo, --noui / нет cgo → headless)
scripts/ build-publish-ui.ps1 — локальная Windows-сборка Fyne-бинаря + публикация в Gitea
docs/ ui-spec.md — спека Fyne UI (слои, event-bus, fyne.Do/снапшоты)
``` ```
## Ключевые инварианты ## Ключевые инварианты
- **Команды/ядро:** Core.ProcessTurn — state-machine поверх storage. Команды: - **App.New-сигнатура:** `App.New(configPath, version, updateToken string, noUI bool)` (4-й параметр — headless; cgo-вариант собирается только при наличии C-компилятора).
/start /cancel /skip /retry N /status N /continue N. Активная задача — одна на чат. - **UI:** окно — ещё одна реализация `chat.Channel` (присоединяется в Router). Core не трогает UI; обмен — событийная шина (events). Кнопка «Завершить» = полный выход (SetOnQuit→cancel→UI.Run возвращается); закрытие крестиком = сворачивание, Core живёт. UI собирается с `--noui`/без cgo.
- **Фазы аналитика (Decision.Phase):** `ask` (уточняющие вопросы), `propose` (правки - **Фазы аналитика (Decision.Phase):** `ask`, `propose`, `ready` (два последних обрабатываются одинаково в core), `abort`. Требования валидатора: ask — chat_reply/questions; propose — хотя бы одно изменённое поле; ready — без изменённых полей.
черновика), `ready` (черновик полон как есть), `abort` (тема вне проекта). propose и ready - **Статусы задач (internal/model):** draft→collecting→ready→approved→running→success/failed/timeout + cancelled/aborted/closed (терминальные). UserID — chat.ID (одна активная задача на чат).
в core обрабатываются одинаково. - **Decider/Worker/Analyst/Reviewer:** Decider=analyst интерфейс; Worker — polling-планировщик; Reviewer проверяет diff dev-ветки (R1-R6), вердикт JSON {passed, critical_issues, solid_violations, comments}.
- **Статусы задач:** draft→collecting→ready→approved→running→success/failed/timeout, - **gitops (worker):** worktree-режим; feature-ветка `feat/<taskTag>` от origin/main; push через http.extraHeader, токен Bearer.
плюс cancelled/aborted/closed. approved — финальное одобрение («создавай»), после чего - **Пути «всё рядом с .exe»:** db/worktree резолвятся от ExeDir; config.yaml — рядом с бинарём, фоллбэк cwd.
воркер берёт задачу. IsValidTransition/IsTerminal в storage/models.go. - **Автообновление:** авто = только Check+уведомление; замена — по /update; версии в `commit-<sha7>/` (не `latest/`); Verify сверяет предprod-версию (binary+в.в) .
- **Decider/Worker/Analyst/Reviewer:** Decider=analyst интерфейс (analyst пакет реализует);
Worker — polling-планировщик dev-агента; Reviewer проверяет diff dev-ветки (R1-R6),
вердикт JSON {passed, critical_issues, solid_violations, comments}.
- **gitops (worker):** worker работает с worktrees; feature-ветка = `feat/<taskTag>` от
origin/main; ensureBranch (reset --hard + clean -fd + checkout -B), branchDiff
(`origin/main...<branch>`), push через http.extraHeader, токен как Bearer.
- **Пути «всё рядом с .exe»:** относительные db/worktree резолвятся от каталога бинаря
(ExeDir), не от cwd. config.yaml ищутся рядом с бинарём, фоллбэк cwd.
- **Автообновление:** авто = только Check+уведомление; замена — по /update. Версии в
Gitea Packages `commit-<sha7>/` (не `latest/`). Вердикты агентов — строгий JSON.
## opencode (v2 HTTP API, >= 1.18.18) ## opencode (v2 HTTP API, >= 1.18.18)
- Интеграция с субагентами — через headless `opencode serve`, **v2 API** (префикс `/api/*`). - Интеграция с субагентами — через headless `opencode serve`, **v2 API** (`/api/*`). Версия opencode >= 1.18.18.
Минимальная версия opencode **>= 1.18.18** (старый бинарь — только `/global/health`, не годится; - **Хардпин модели:** при создании сессии читается top-level `model` из конфига opencode (`internal/opencode/config.go`, JSONC-стрип) и передаётся в `POST /api/session` как `{"model":{providerID,id}}`.
healthcheck падает с понятной ошибкой, класс O1). - **О5 WARN (устойчивость к v1-конфигу):** конфиг по старой схеме молча игнорируется v2; провайдер без api → unsupported модели → fallback. Ratatoskr не чинит сам, но логирует warning; фактическая модель ответа сравнивается с ожидаемой. Правильный v2-вид: `api:{type:"aisdk",package,url}`, `request.headers` вместо `options.headers`.
- **Хардпин модели:** при создании сессии читается top-level `"model"` из конфига opencode - **Поллинг вердикта:** `POST /api/session/:id/prompt` (durable admit) → `GET /api/session/:id/message?order=desc&limit=200` (новые assistant-сообщения, текст в `content[].type=="text"`) → завершение = `GET /api/session/active` без сессии + финальное assistant-сообщение, стабильное `settlePolls=2` опроса. `POST .../interrupt` вместо abort.
(`internal/opencode/config.go`, JSONC-стрип `//`/`/* */`/trailing-запятых) и передаётся в
`POST /api/session` как `{"model":{providerID,id}}` (разбор `provider/id` по первому `/`).
- **Класс O5 WARN (устойчивость к v1-конфигу):** конфиг по старой v1-схеме
(`provider.X.npm`/`options`) молча игнорируется v2 → провайдер без api → модель unsupported →
fallback. Раtatoskr не чинит это сам, но логирует warning: конфиг не читается/нет `model`,
и/или фактическая модель ответа (из assistant-сообщения) ≠ ожидаемой. Правильный v2-вид:
`api:{type:"aisdk",package,url}` и `request.headers` вместо `options.headers`.
- **Поллинг вердикта:** `POST /api/session/:id/prompt` (durable admit, неблокирующий) →
`GET /api/session/:id/message?order=desc&limit=200` (новые assistant-сообщения, текст в
`content[].type=="text"`) → завершение = `GET /api/session/active` без сессии + финальное
assistant-сообщение, стабильное `settlePolls=2` опроса. `POST .../interrupt` вместо abort.
- `ModelRef{ProviderID,ID,Variant}` — аналог v2 Model.Ref; `MinVersion="1.18.18"` в server.go.
## Контракты (не ломать) ## Контракты (не ломать)
- `App.New(configPath, version, updateToken string)` — сигнатура. - `App.New(configPath, version, updateToken string, noUI bool)` — сигнатура.
- `packageOwner` — константа "kamelion" (не плодить vars/ldflag/конфиг). - `packageOwner` — константа "kamelion" (не плодить vars/ldflag/конфиг).
- `update.Updater` — создаётся структурой `&update.Updater{...}`, конструктора нет. - `update.Updater` — создаётся структурой `&update.Updater{...}`, конструктора нет.
- `ui.Store` — чтение-модель для UI: ListTasks/GetTask/GetHistory/GetTraces (копии, без ссылок на storage).
- `events.Bus`/`events.LogBus` — шина UI (см. internal/events). publisher-интерфейсы подключаются к Core/Worker.
См. также `mem:tech_stack`, `mem:conventions`, `mem:task_completion`, `mem:suggested_commands`. см. также `mem:tech_stack`, `mem:conventions`, `mem:task_completion`, `mem:suggested_commands`.

View File

@@ -6,22 +6,27 @@
- `go fmt ./...` (или `make fmt`). - `go fmt ./...` (или `make fmt`).
- `go build -o ratatoskr ./cmd/ratatoskr/` (или `make build`). - `go build -o ratatoskr ./cmd/ratatoskr/` (или `make build`).
- `./ratatoskr -config config.yaml` (или `make run`). - `./ratatoskr -config config.yaml` (или `make run`).
- `./ratatoskr -version` показать версию бинаря. - `./ratatoskr -version` — версия бинаря; `./ratatoskr -noui` — headless (без окна).
- Кросс-сборка: `make cross` (linux/amd64 + windows/amd64). - Кросс-сборка: `make cross` (linux/amd64 + windows/amd64, headless).
## Windows / UI (Fyne)
- Сборка с UI (cgo): `powershell -ExecutionPolicy Bypass -File scripts/build-publish-ui.ps1`.
Требует `scripts/.gitea-creds` (не коммитить) и MinGW/gcc в PATH. См. `mem:toolchain/cgo-winlibs-gcc`.
- Обычная headless-сборка на Windows без cgo: `go build -o ratatoskr.exe ./cmd/ratatoskr/`.
## Go-тулчейн ## Go-тулчейн
Хост без `go` в PATH — экспорт вручную: Хост без `go` в PATH — экспорт вручную:
``` ```
export PATH=/opt/data/.local/go/bin:$PATH export PATH=/opt/data/.local/go/bin:$PATH
``` ```
На машине разработки (Windows, cmd) — обычный system Go. На машине разработки (Windows) — обычный system Go.
## git (worktree-процесс Ratatoskr) ## git (worktree-процесс Ratatoskr)
- Feature-ветка: `feat/<taskTag>`, база — `origin/main`. Пример проверки diff всей ветки: - Feature-ветка: `feat/<taskTag>`, база — `origin/main`. Проверка diff всей ветки:
`git diff origin/main...HEAD`. `git diff origin/main...HEAD`.
- Проверить состав отслеживаемых файлов (например наличие .serena): `git ls-files`. - Проверить состав отслеживаемых файлов (например наличие .serena): `git ls-files`.
- Статус: `git status`. Лог: `git log --oneline -10`. - Статус: `git status`. Лог: `git log --oneline -10`.
## Замечания про среду ## Замечания про среду
- ОС Windows + PowerShell 5.1 (shell: powershell) — команды собирать/запускать с учётом - ОС Windows + PowerShell 5.1 (shell: powershell) — нет `&&`; использовать `;`/`if ($?) {}`;
этом (нет `&&`; использовать `;`/`if ($?) {}`; & для путей с пробелами). `&` для вызова путей с пробелами. Для серенных файлов лучше отдельная команда `serena memories check`.

View File

@@ -6,9 +6,10 @@
2. **Тесты:** `go test ./... -v -count=1 -timeout 120s` — все проходят (`make test`). 2. **Тесты:** `go test ./... -v -count=1 -timeout 120s` — все проходят (`make test`).
3. **Статический анализ:** `go vet ./...` — чисто (`make vet`). 3. **Статический анализ:** `go vet ./...` — чисто (`make vet`).
4. **Сборка:** `go build -o ratatoskr ./cmd/ratatoskr/` (`make build`) — компилируется. 4. **Сборка:** `go build -o ratatoskr ./cmd/ratatoskr/` (`make build`) — компилируется.
(Кросс-сборка `make cross` — только при необходимости.) - UI-сборка (cgo+Fyne) проверяется отдельно (`scripts/build-publish-ui.ps1` или `go build` с MinGW);
headless-сборка cgo-пакетов (`internal/ui/desktop`) — только typecheck.
5. **Пересмотр контрактов:** если менялась сигнатура `App.New(configPath, version, 5. **Пересмотр контрактов:** если менялась сигнатура `App.New(configPath, version,
updateToken string)` — обновить вызовы в `internal/app/app_test.go` (иначе go vet падает). updateToken string, noUI bool)` — обновить все вызовы (app_test.go, main.go).
6. Коммит осмысленными атомарными коммитами в feature-ветку `feat/<taskTag>` от origin/main. 6. Коммит осмысленными атомарными коммитами в feature-ветку `feat/<taskTag>` от origin/main.
Рабочий процесс Ratatoskr (агент dev в этом конвейере): изучить код, реализовать так, чтобы Рабочий процесс Ratatoskr (агент dev в этом конвейере): изучить код, реализовать так, чтобы

View File

@@ -1,32 +1,32 @@
# tech_stack # tech_stack
## Язык / рантайм ## Язык / рантайм
- Go **1.25.0** (go.mod `go 1.25.0`). Модуль `github.com/kamelion/ratatoskr-go`. - Go **1.25.0** (go.mod `go 1.25.0`, module `github.com/kamelion/ratatoskr-go`).
- Сборка: статический бинарь, `CGO_ENABLED=0`. Локальный Go-тулчейн на хосте: - Headless-сборка статический бинарь `CGO_ENABLED=0`. Windows-сборка с UI — cgo (Fyne/GLFW) + MinGW-w64/gcc.
`export PATH=/opt/data/.local/go/bin:$PATH` (go1.25.0 linux/amd64). - Локальный Go-тулчейн на хосте: `export PATH=/opt/data/.local/go/bin:$PATH` (go1.25.0 linux/amd64). На Windows — обычный system Go.
## Основные зависимости (go.mod) ## Основные зависимости (go.mod)
- `fyne.io/fyne/v2 v2.6.0` — desktop UI (cgo/GLFW; платформа Windows + Linux). Не собирается в CI (headless).
- `gopkg.in/yaml.v3 v3.0.1` — парсинг config.yaml. - `gopkg.in/yaml.v3 v3.0.1` — парсинг config.yaml.
- `modernc.org/sqlite v1.56.0` — SQLite без CGO (чистый Go). - `modernc.org/sqlite v1.56.0` — SQLite без CGO (чистый Go).
- (indirect) google/uuid, go-humanize, mattn/go-isatty, x/sys, modernc.org/libc/mathutil/memory. - (indirect) google/uuid, go-humanize, go-text/typesetting, x/sys, x/net, modernc.org/libc и пр.
## Внешние процессы ## Внешние процессы
- **opencode** (opencode.ai) — внешний процесс для субагентов (analyst/dev/reviewer). - **opencode** (opencode.ai) — субагенты (analyst/dev/reviewer). Управляется через internal/opencode (Runner, Pool, LiveRegistry). Требуемая версия **>= 1.18.18** (v2 HTTP API `/api/*`).
Управляется через internal/opencode (Runner, LiveRegistry). Настраивается в конфиге - Настройки в конфиге: `opencode.bin / config / config_dir / hard_timeout / idle_timeout / poll_ms` + `opencode.server` (hostname, port, password).
(opencode.bin / config / config_dir / hard_timeout / idle_timeout / poll_ms). - Подробно — `mem:core`.
Требуемая версия opencode: **>= 1.18.18** (v2 HTTP API `/api/*`; хардпин model из конфига —
`internal/opencode/config.go`, классы O1/O5). Подробно — `mem:core`.
## Сборка / Makefile ## Сборка / Makefile
- `make build` — go build -ldflags="-s -w -X main.version=commit-<sha7> -X main.updateToken=..." -o ratatoskr ./cmd/ratatoskr/. - `make build` — go build `-ldflags="-s -w -X main.version=commit-<sha7> -X main.updateToken=..." -o ratatoskr ./cmd/ratatoskr/`.
- `make test` — go test ./... -v -count=1 -timeout 120s. - `make test``go test ./... -v -count=1 -timeout 120s`.
- `make vet` — go vet ./... `make fmt` — go fmt ./... - `make vet``go vet ./...`; `make fmt``go fmt ./...`.
- `make run` — build + ./ratatoskr -config config.yaml. - `make run` — build + `./ratatoskr -config config.yaml`.
- `make cross` кросс-сборка linux/amd64 + windows/amd64 (ratatoskr-windows-amd64.exe — Windows-машина Камиля). - `make cross` — linux/amd64 + windows/amd64 (headless, БЕЗ Fyne-UI; для UI нужен cgo).
- GIT_SHA вшивается в main.version; UPDATE_TOKEN — в main.updateToken (секрет только у CI). - **Fyne-UI (Windows) собирается вручную** на машине с MinGW:
`scripts/build-publish-ui.ps1` (cgo) — сборка + публикация в Gitea в версию `commit-<sha7>`.
Требует `scripts/.gitea-creds` (git-ignored) и gcc в PATH. См. `mem:toolchain/cgo-winlibs-gcc`.
## CI (.gitea/workflows/ci.yaml) ## CI (.gitea/workflows/ci.yaml)
- Job `test`: go test ./... + go vet ./.... - Job `test`: `go test ./...` + `go vet ./...` (setup-go '1.23', check-latest).
- Job `build-and-package` (matrix linux/amd64+windows/amd64): собирает и публикует в - Job `build-and-package` (matrix linux/amd64 + windows/amd64): собирает headless-бинарь с ldflag update_token, публикует на `main` в версию `commit-<sha7>/` (+ companion `.version`/`.sha256`).
Gitea Packages на `main` в версию `commit-<sha7>/` + companion-файлы `.version`/`.sha256`. - Секреты: `TC_GITEA_TOKEN` (write:packages), `TC_UPDATE_TOKEN` (read:package), vars `GIT_MAIN_URL`.
- Секреты: TC_GITEA_TOKEN (write:packages), TC_UPDATE_TOKEN (read:package), GIT_MAIN_URL.

View File

@@ -0,0 +1,14 @@
# cgo/winlibs-gcc
## MinGW/WinLibs для Fyne-сборки на Windows
- UI (internal/ui/desktop, fyne.io/fyne/v2) требует cgo + C-компилятор для GLFW.
- Установлен постоянно в `D:\MinGW\bin` (WinLibs x86_64-ucrt-posix-seh, GCC 16.2.0),
файл `D:\MinGW\bin\gcc.exe` существует. НО каталог не гарантированно в PATH текущей
сессии (проверено 2026-08-21: `D:\MinGW\bin` отсутствовал в PATH, `gcc` не находился).
`scripts/build-publish-ui.ps1` сам проверяет gcc и при отсутствии подсказывает ручную
подстановку PATH (стр. 3641): `$env:Path = "<...>\mingw64\bin;$env:Path"`.
- Прежняя winget-версия (BrechtSanders.WinLibs.POSIX.UCRT) удалена, чтобы не было дубликата.
- Проверка: `gcc --version`; также убедиться, что `CGO_ENABLED` не = "0".
- Care: не использовать Cygwin/MSYS-сборки gcc — только native MinGW-w64 (WinLibs).
- Сборка UI выполняется локально на Windows-машине, публикуется в Gitea Packages
в в версию `commit-<sha7>` со companion-.version/.sha256 (контракт U4) — см. скрипт и `mem:tech_stack`.

View File

@@ -27,6 +27,7 @@ var updateToken = ""
func main() { func main() {
cfg := flag.String("config", "", "путь к config.yaml (по умолчанию — CWD/config.yaml)") cfg := flag.String("config", "", "путь к config.yaml (по умолчанию — CWD/config.yaml)")
versionFlag := flag.Bool("version", false, "показать версию и выйти") versionFlag := flag.Bool("version", false, "показать версию и выйти")
noUI := flag.Bool("noui", false, "работать без графического окна (headless)")
flag.Parse() flag.Parse()
if *versionFlag { if *versionFlag {
@@ -34,7 +35,7 @@ func main() {
return return
} }
a, err := app.New(*cfg, version, updateToken) a, err := app.New(*cfg, version, updateToken, *noUI)
if err != nil { if err != nil {
log.Fatalf("app init: %v", err) log.Fatalf("app init: %v", err)
} }
@@ -60,4 +61,4 @@ func main() {
} }
log.Print("ratatoskr: остановлен") log.Print("ratatoskr: остановлен")
os.Exit(0) os.Exit(0)
} }

252
docs/ui-spec.md Normal file
View File

@@ -0,0 +1,252 @@
# UI-спека Ratatoskr (Fyne Desktop)
Статус: утверждается. Черновик, обсуждаем дальше (архитектура обмена UI <-> Core).
Стек: **Go + Fyne** (свежая стабильная версия). Платформа: **Windows** (Linux пока не поддерживаем).
---
## 1. Цель и роль UI
1. UI — **полноценный инструмент** (не только админ-панель): управляет всем, чем управляет Telegram, и становится **основным** интерфейсом.
2. Один пользователь, один человек (без multi-user / мультисессии).
3. UI должен уметь **всё, что умеет Telegram-интерфейс**, и станет основным; Telegram остаётся как один из каналов.
4. UI — **графическая оболочка самого приложения** (одна программа), не отдельный process.
5. Внутри оболочки запускается **ботик сам**; UI — это лишь ещё одна реализация `chat.Channel`.
6. Запуск: **обычный запуск бинаря** → UI. Режим без UI — только по флагу **`--noui`**.
- Логика: UI включён по умолчанию, headless — опционально (`--noui`).
7. Трей-иконка не нужна.
8. При закрытии окна — **сворачивать** (не завершать процесс). Для полного выхода — **отдельная кнопка** «Завершить», чтобы бот остановился.
9. Конфиг-секции редактировать в UI пока не нужно; **hot-reload конфига** — желательно, если реализуемо (запланировать как «если возможно — да»).
## 2. Компоновка (layout)
- Базовый grid по умолчанию:
- **слева — список задач** (широкая колонка),
- **по центру/право — рабочая область** (в ней живёт «работа бота» / live-шаги);
- рабочая область может **разделяться на сплит-панели** (2×2, не считая левой колонки с задачами).
- Табы пока **не делаем** (первый этап).
- Панели должны быть **сворачиваемыми** (хотя бы левая колонка с задачами).
- Сохранение layout (положение/размеры панелей) — **нужно**, переживает перезапуск приложения (Fyne Preferences).
- Окно: **на весь экран по умолчанию**.
- Нижняя панель: **вкладки «Логи», «Состояние»**:
- **Логи** — ведение логов (поток системных логов);
- **Состояние** — живое состояние текущих задач / на каком статусе сейчас задача.
- Верхняя панель: **меню**, пока только пункт «О программе» (About → About).
- Тема: **тёмная**, без переключателя темы; стартуем с **дефолтной темы Fyne**.
- Локаль: **en** (интерфейс на английском).
## 3. Данные задач
- Задачи отображаются **списком** слева: **краткое название + служебное** (воркшоп).
- Детализация списка (статус/фильтры/поиск/сортировка/по умолчание/пагинация) — **дорабатываем потом**.
- В деталях задачи (рабочая область) показываем все перечисленные поля:
- Title, Goal, Why, AC (acceptance criteria), Repo(s), Tag (task_tag), статус, CreatedAt/UpdatedAt, вложенные трассы (traces), история чата (history), включая (session id opencode).
- История чата из БД (user/assistant) — лента сообщений.
- Трейсы агентов (аналист/dev/reviewer) — дерево шагов/ходов + raw output.
- Live-шаги (LiveRegistry) — восстановить и показывать в реальном времени.
## 4. Действия с задачами
- UI поддерживает все действия, доступные в TG: создание задачи, изменение, **approve**, **rework доработка**, **cancel**, **retry**, закрытие (skip/continue), статусы — в рамках валидных переходов.
- Создание задачи — форма с полями (Title, Goal/Why, Repos, AC) (какие обязательные — уточнить).
- Работа с статусами: эмуляция команд `/approve`, `/retry`, `/cancel` и т.п. (валидные переходы по state machine)
- Подтверждения опасных действий (удаление задачи, и т.п.).
- Редактирование полей (repo, AC, tags) из UI — по необходимости.
## 7. Мониторинг системы
- Показывать **логи** (готовый поток из `log`), **фреймы состояния** бота:
- статус opencode serve/pool, коннекции, активные сессии.
- Live-статус задач (`/status N`) эквивалентом — во вкладке «Состояние».
- Нужен индикатор занятости агента («опенкод думает/dev writing…»).
## 8. Настройки / конфиг
- Настройки в UI пока **не выводим**; секреты никоим образом не показываем.
- Кнопка «Завершить» (выход приложения) — в меню.
- Перезапуск бота из UI — пока не надо.
## 9. Живое обновление / консистентность
- **Шина событий (event-bus)** — запланирована: UI получает события из ядра (новые задачи, изменение статусов, live-шаги, логи). Явно нужен.
- UI и Telegram — **оболочки** вокруг одного ядра; обмен через **интерфейс (абстракцию)**, не зашумляя ядро.
- Консистентность между TG и UI при параллельных изменениях — через события ядра; архитектура обмена — см. раздел 12.
## 10. Технические
- **Fyne**: свежая стабильная (последняя).
- **Абстракция БД**: доступ к storage через **интерфейс** (не прямиком к *storage.Storage) для тестируемости.
- Пакет: **`internal/ui`** (+ подпакеты при необходимости).
- Тесты: юнит-тесты модели, **без e2e/UI-тестов** пока.
- Использование данных: **вся история** (без ограничения по времени).
## 11. Скоуп первого этапа
- Минимальный UI, покрывающий **функционал Telegram-интерфейса** (все команды/действия).
- Также UI — основной интерфейс (TG остаётся каналом).
---
## 12. Архитектура обмена UI ↔ Core (best practices)
### 12.1. Слои и направление зависимостей
```
┌──────────────┐ команды → ┌─────────────────┐ заказы/готовое → ┌──────────────┐
│ UI (Fyne) │ ───────────▶ │ Application / │ ─────────────────▶ │ Domain/core │
│ «представ- │ │ UseCase слой │ события (events) │ (model) │
│ ление» │ ◀─────────── │ │ ◀────────────────── │ │
└──────────────┘ события ← └─────────────────┘ └──────────────┘
```
- **Core** (`internal/core`.ProcessTurn — state-machine) **не трогаем**. Он — источник правды.
- **App** (`internal/app`) — use-case/композиция: политика «одна активная задача на чат», `Notify`, роутинг команд.
- **UI** — ещё одна реализация `chat.Channel` (параллельно Telegram). Уже есть идеальный «port»: интерфейс `Channel{Run, OnMessage, Send, Ask, Close}`.
### 12.2. UI получает состояние, а не управляет Core'ом (uni-directional data flow)
- Core **мутирует состояние** (БД, worker, аналитик). UI — только **читает снимки** и реагирует на события, обновляя своё view-model (не БД и не core).
- Действия UI = **команды** (`CreateTask`, `ApproveTask`, `RetryTask`, `CancelTask`, `ContinueTask`), которые вызывают use-case в Core.
- Это даёт: единый источник правды, простую отладку, лёгкое тестирование без UI.
### 12.3. Обмен = событийная шина (event bus / pub-sub)
- **Core — публикует** доменные события, UI — **подписчик**:
- `TaskCreated{id, chatID}`
- `TaskStatusChanged{id, from, to}` (ready/approved/running/success/failed/timeout…)
- `TaskHistoryAppended{id, role, content}`
- `TraceAppended{id, agent, status, output}`
- `AgentActivity{id, agent, stage}` (live-шаги, индикаторы занятости)
- `LogLine` (системные логи для вкладки «Логи»)
- **Направление однонаправленное**: core/worker/analyst не знают, кто подписан; никаких прямых вызовов Fyne из core!
- Реализация малая: `Bus` с `Subscribe/Unsubscribe/Publish` + buffered channels (`n`-подписчиков или `select`); паттерн уже есть в `chat.Router` (внутренний `incoming` channel).
- Сигнатуры событий использовать **и для консистентности** TG↔UI (оба канала — простые подписчики/клиенты шины).
### 12.4. Модель потоков Fyne (v2.6+) и `fyne.Do`
- Fyne с v2.6.0 выполняет все события/колбэки **на одной главной goroutine**.
- Фоновые goroutine (worker, opencode polling, ход core) **не должны модифицировать UI напрямую**.
- Любое обновление виджетов из своей goroutine — через:
- `fyne.Do(func(){ widget.X = …; widget.Refresh() })` — очередь в следующий кадр;
- `fyne.DoAndWait(...)` — когда надо дождаться завершения;
- унифицировать с **binding** (`binding.String`, `binding.List`) — через `fyne.Do` не требуется, но проще явно.
- Подписчик шины ставит snapshot в очередь UI-обновления и вызывает `fyne.Do`.
### 12.5. Чтение-модель на UI: снапшоты, не мутабельные указатели
- UI получает **копии** записей (Task, history, traces) через абстракцию БД (см. раздел 10).
- Никаких референсов на разделяемые контейнеры core; используются лёгкие view-копии (Value-типы/DT-коды) — нет гонок bottom-up.
### 12.6. Жизненный цикл: Core ≠ UI
- Core (бот) живёт независимо от окна. Закрытие окна = **сворачивание** (Core продолжает).
- Полный выход — только кнопка «Завершить» → `app.Quit()` (Worker.Stop → router.Close → pool.Close → store.Close).
- `--noui` → core запускается без создания окна.
### 12.7. Скоуп реализации (поэтапно)
- [x] **Фаза 1**: `internal/ui` — Store-абстракция (копии), окно как `chat.Channel`,
команды через канал, snapshots из БД. Fyne-реализация — `internal/ui/desktop`
(build-tag `cgo`, требует компилятор C для GLFW).
- [x] **Фаза 2**: `internal/events` — Bus/Hub, Core публикует события, UI подписывается
через `Controller` (статусы, история, live-шаги, логи).
- [ ] **Фаза 3**: консистентность TG↔UI через общий маршрутизатор/шину (`UserID`, `chat.Router`).
Примечание: окно (Фаза 1) собирается только с cgo; на машинах без C-компилятора
приложение работает headless (UI=nil). Реальное окно полноценно проверяется
на машине с MinGW-w64/gcc (`go build -ldflags ...`), здесь — только typecheck.
### 12.8. Структура UI-кода: компоненты-виджеты (рефакторинг)
`internal/ui` (без Fyne) — контракты + Nil-реализации + юнит-тесты. Fyne-код —
только в `internal/ui/desktop/*`, один файл — одна панель. `desktop/window.go`
становится чистой компоновкой.
Каждый компонент:
- **интерфейс** в `internal/ui/<panel>.go` (контракт для тестов/headless);
- **Nil-реализация** в том же файле (headless/тесты);
- **Fyne-реализация** в `internal/ui/desktop/<panel>.go`.
### 12.9. Контракт виджета «Логи» (LogPanel)
Панель «Логи» — поток системных логов из `events.LogLine` (уровень + строка).
- `Append(level, text)` — добавить запись лога; уровень нормализуется к одному
из четырёх (`ui.NormalizeLogLevel`: error/warning/info/debug, неизвестные →
info); при превышении лимита старые записи отбрасываются (буфер ограничен,
не расти бесконечно).
- **Фильтр уровней**: над лентой — выпадающий список с чекбоксами
(error/warning/info/debug). По умолчанию отмечены все уровни (виден полный
лог); показываются только записи выбранных уровней; изменение набора сразу
перерисовывает ленту — и по уже загруженным записям, и для вновь
поступающих. Если ни один уровень не выбран — лента пуста (без ошибок).
- Контейнер виджета — отдаётся окну для встраивания в таб «Логи» (строка
фильтра + Scroll с лентой).
- Обновление содержимого — на потоке Fyne (внутри панели `fyne.Do`), не из
горутины Hub.
### 12.10. Контракт виджета «Список задач» (TaskListPanel)
Список задач — левая панель окна: все задачи (`storage.Task`) строками.
- `SetTasks(tasks)` — заменить снимок списка и перерисовать виджет. Данные —
копии из Store (спец 12.5).
- `OnSelect(fn)` — колбэк клика по строке; окно грузит детали выбранной задачи
(это `task_detail`, отдельный компонент). Состояние выбора живёт в окне.
- Выбранная строка подсвечивается (`Select`/`Unselect`), окно управляет ею.
- Строка = `taskTitle(t)` (pure-функция, тестируется без Fyne).
- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`, панель сама
виджет не трогает из горутин Hub.
### 12.11. Контракт виджета «Детали задачи» (TaskDetailPanel)
Детали выбранной задачи — верх `right`-сплита: заголовок, статус, детали
(цель + репозитории).
- `ShowTask(t)` — рендер из снимка `storage.Task`: заголовок (bold), статус
(italic), детали = `TaskDetailText(t)`.
- `ShowEmpty()`сброс к placeholder («—» / пусто) при недоступной задаче.
- Строка деталей = pure `TaskDetailText(t)` (цель + репо), тестируется без Fyne.
- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`.
- Диалог и «Состояние» — отдельные панели (chat_panel, state_panel), здесь
не участвуют; окно оркестрирует загрузку всех панелей по выбору задачи.
### 12.12. Контракт виджета «Диалог» (ChatPanel)
Диалог — транскрипт общения с выбранной задачей + композитор (поле ввода и
кнопки команд).
- Транскрипт:
- `SetTranscript(text)` — полный рендер истории выбранной задачи;
- `Append(text)` — добавить строку; при превышении лимита старые строки
отбрасываются (буфер ограничен);
- `Clear()` — очистить при недоступной задаче.
- Композитор: поле ввода + кнопки команд, подключённые к `ui.Commands` через
`SetCommands(c)` (ввод → `SendText`, кнопки → Start/Approve/Skip/Cancel).
- Доставка: окно (как chat.Channel) рендерит `Send`/`Ask`/историю через
`Append`; строка роли = pure `FormatRole(role)` (👤/🤖).
- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`.
### 12.13. Контракт виджета «Состояние» (StatePanel)
Панель «Состояние» — агенты + трейсы выбранной задачи: снимок и live-строки.
- `SetText(text)` — полный рендер снимка трасс выбранной задачи.
- `Append(text)` — добавить строку; при превышении лимита старые строки
отбрасываются (буфер ограничен).
- Строка снимка = pure `StateText(traces)` (агент: статус, session).
- Live-строки (чистые хелперы, окно зовёт `Append` с готовой строкой):
- `TraceLine(agent, status)` — добавлен/обновлён трейс субагента;
- `ActivityLine(agent, stage)` — смена этапа агента;
- `TaskStatusText(e)` — изменение статуса задачи (бейдж + ID).
- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`.
---
## Открытые пункты (TODO)
- [ ] Точно определить set сплит-панелей (2x2 центр) и как добавляются
- [ ] Обязательные поля формы создания задачи
- [ ] Подробности виджета списка задач (после первого мильстон)
- [x] Архитектура обмена UI<->Core (раздел 12 — принципы; детальные контракты событий/интерфейсов — следующая итерация)

31
go.mod
View File

@@ -3,17 +3,48 @@ module github.com/kamelion/ratatoskr-go
go 1.25.0 go 1.25.0
require ( require (
fyne.io/fyne/v2 v2.6.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.56.0 modernc.org/sqlite v1.56.0
) )
require ( require (
fyne.io/systray v1.11.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fyne-io/gl-js v0.1.0 // indirect
github.com/fyne-io/glfw-js v0.2.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.1.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rymdport/portal v0.4.1 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.22.0 // indirect
modernc.org/libc v1.74.4 // indirect modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

74
go.sum
View File

@@ -1,27 +1,99 @@
fyne.io/fyne/v2 v2.6.0 h1:Rywo9yKYN4qvNuvkRuLF+zxhJYWbIFM+m4N4KV4p1pQ=
fyne.io/fyne/v2 v2.6.0/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=

View File

@@ -21,7 +21,7 @@ var FS embed.FS
// //
// Держим в курсе: если добавить файл в каталог, он автоматически попадёт // Держим в курсе: если добавить файл в каталог, он автоматически попадёт
// в FS, но для детерминированной распаковки список лучше дополнять здесь. // в FS, но для детерминированной распаковки список лучше дополнять здесь.
var Names = []string{"analyst", "dev", "reviewer"} var Names = []string{"analyst", "dev", "reviewer", "chat"}
// WriteTo распаковывает всех встроенных агентов в каталог dir/agents // WriteTo распаковывает всех встроенных агентов в каталог dir/agents
// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна. // (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.
@@ -37,4 +37,4 @@ func WriteTo(dir string) error {
} }
} }
return nil return nil
} }

17
internal/agents/chat.md Normal file
View File

@@ -0,0 +1,17 @@
---
name: chat
description: Свободный чат с Ratatoskr — прямой диалог с LLM без задачи и JSON-вердиктов
mode: primary
---
Ты — полезный ассистент в диалоговом режиме. Пользователь пишет тебе произвольные
сообщения; отвечай на них прямо и по делу, на русском языке, естественно.
Особенности:
1. Отвечай на вопросы пользователя, помогай с задачами разработки, поясняй код.
2. Не оборачивай ответ в JSON и не возвращай структурированные вердикты — это
свободный диалог, не конвейер задач Ratatoskr.
3. Если тебе не хватает контекста — задай уточняющий вопрос в обычном тексте.
4. Пиши кратко и по существу, если пользователь не просит развёрнуто.
Просто продолжай диалог.

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/kamelion/ratatoskr-go/internal/core" "github.com/kamelion/ratatoskr-go/internal/core"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage" "github.com/kamelion/ratatoskr-go/internal/storage"
) )
@@ -35,6 +36,16 @@ type Analyst struct {
Runner OpenCodeRunner Runner OpenCodeRunner
Worktree string // каталог, откуда запускать opencode run Worktree string // каталог, откуда запускать opencode run
Agent string // имя агента (default "analyst") Agent string // имя агента (default "analyst")
// Events — издатель доменных событий для UI. nil — события выключены.
Events events.Publisher
}
// publish отправляет доменное событие, если задан издатель.
func (a *Analyst) publish(e events.Event) {
if a.Events != nil {
a.Events.Publish(e)
}
} }
// AnalystResponse — структура JSON-ответа аналитика. // AnalystResponse — структура JSON-ответа аналитика.
@@ -58,6 +69,8 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor
agent = "analyst" agent = "analyst"
} }
a.publish(events.AgentActivity{TaskID: draft.ID, Agent: agent, Stage: "decide"})
if len(history) == 0 && !force { if len(history) == 0 && !force {
return core.Decision{}, fmt.Errorf("%w: пустая история диалога", ErrNotReady) return core.Decision{}, fmt.Errorf("%w: пустая история диалога", ErrNotReady)
} }

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"os/signal" "os/signal"
@@ -19,8 +20,10 @@ import (
"github.com/kamelion/ratatoskr-go/internal/chat/telegram" "github.com/kamelion/ratatoskr-go/internal/chat/telegram"
"github.com/kamelion/ratatoskr-go/internal/config" "github.com/kamelion/ratatoskr-go/internal/config"
"github.com/kamelion/ratatoskr-go/internal/core" "github.com/kamelion/ratatoskr-go/internal/core"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage" "github.com/kamelion/ratatoskr-go/internal/storage"
"github.com/kamelion/ratatoskr-go/internal/ui"
"github.com/kamelion/ratatoskr-go/internal/update" "github.com/kamelion/ratatoskr-go/internal/update"
"github.com/kamelion/ratatoskr-go/internal/worker" "github.com/kamelion/ratatoskr-go/internal/worker"
) )
@@ -61,6 +64,44 @@ type App struct {
Updater *update.Updater Updater *update.Updater
tg *telegram.Channel // сохранена для Run tg *telegram.Channel // сохранена для Run
pool *opencode.Pool // пул opencode serve-серверов (API-режим) pool *opencode.Pool // пул opencode serve-серверов (API-режим)
// Events — доменная шина UI; LogEvents — шина логов (панель «Логи»).
Events *events.Bus
LogEvents *events.LogBus
// UI — десктопное окно (если собрано с cgo и не задан --noui).
// nil — headless-режим (без окна).
UI ui.Window
Controller *ui.Controller
// FreeChat — свободные диалоги с LLM вне машины задач (in-memory).
FreeChat *FreeChat
}
// FreeChat — свободный LLM-чат (без задачи). Каждый UserID (вкладка UI)
// держит свою opencode-сессию, чтобы контекст вкладки не смешивался.
// Транскрипт живёт в памяти окна; здесь только связка uid → opencode sessionID.
type FreeChat struct {
Runner analyst.OpenCodeRunner
Worktree string
sessions map[chat.UserID]string // uid → opencode sessionID
}
// NewFreeChat создаёт сервис свободного чата.
func NewFreeChat(runner analyst.OpenCodeRunner, worktree string) *FreeChat {
return &FreeChat{Runner: runner, Worktree: worktree, sessions: map[chat.UserID]string{}}
}
// Chat отправляет текст пользователя свободной сессии в opencode (agent="chat")
// и возвращает ответ модели. Сессия продолжается (resume по sessionID), поэтому
// каждая вкладка ведёт свой независимый диалог.
func (f *FreeChat) Chat(ctx context.Context, uid chat.UserID, text string) (string, error) {
sid := f.sessions[uid]
res, err := f.Runner.Run(ctx, text, f.Worktree, "chat", sid)
if err != nil {
return "", err
}
f.sessions[uid] = res.SessionID
return res.Stdout, nil
} }
// New читает конфиг и собирает все зависимости. // New читает конфиг и собирает все зависимости.
@@ -68,7 +109,7 @@ type App struct {
// version — вшитая версия бинаря (ldflag -X main.version). // version — вшитая версия бинаря (ldflag -X main.version).
// updateToken — вшитый токен read:package для авто-обновления // updateToken — вшитый токен read:package для авто-обновления
// (ldflag -X main.updateToken); имеет приоритет над update.token из конфига. // (ldflag -X main.updateToken); имеет приоритет над update.token из конфига.
func New(configPath, version, updateToken string) (*App, error) { func New(configPath, version, updateToken string, noUI bool) (*App, error) {
cfg, err := config.Load(configPath) cfg, err := config.Load(configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrConfig, err) return nil, fmt.Errorf("%w: %v", ErrConfig, err)
@@ -144,8 +185,37 @@ func New(configPath, version, updateToken string) (*App, error) {
CoreCtx: coreCtx, CoreCtx: coreCtx,
pool: ocPool, pool: ocPool,
} }
// Шины событий для UI: доменная (статусы/история/трейсы) и логовая
// (сырые строки в панель «Логи»). Издатели подключаются к Core/Worker/
// Analyst; на время headless (--noui) лог дублируется в обе шины, а UI
// просто подписывается на уже существующие.
a.Events = events.New(256)
a.LogEvents = events.NewLogBus(1024)
log.SetOutput(io.MultiWriter(os.Stderr, events.NewLogWriter(a.LogEvents)))
// Подключаем издателей событий к Core/Worker/Analyst.
coreCtx.Events = a.Events
analystCtx.Events = a.Events
router := chat.NewRouter(a.handleIncoming) router := chat.NewRouter(a.handleIncoming)
// UI-канал: ещё одна реализация chat.Channel (спец 12.1).
// Собирается только при наличии cgo и отсутствии --noui.
if !noUI {
win := newUIWindow(ui.NewDBStore(store))
if win != nil {
if err := router.Attach(win); err != nil {
store.Close()
return nil, fmt.Errorf("attach ui: %w", err)
}
a.UI = win
// Контроллер подписывается на шины и мостит события в окно (fyne.Do).
a.Controller = ui.New(a.Events, a.LogEvents, win)
a.Controller.Start()
}
}
// Telegram-канал // Telegram-канал
tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration()) tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration())
a.tg = tg a.tg = tg
@@ -166,10 +236,14 @@ func New(configPath, version, updateToken string) (*App, error) {
GitToken: cfg.Git.Token, GitToken: cfg.Git.Token,
Live: live, Live: live,
Notify: a, // авто-уведомления владельцу задачи через Router Notify: a, // авто-уведомления владельцу задачи через Router
Events: a.Events,
} }
a.Router = router a.Router = router
a.Worker = w a.Worker = w
// Свободный LLM-чат (вкладки UI без задачи): общий Runner, свой sessionID на uid.
a.FreeChat = NewFreeChat(ocRunner, cfg.Paths.Worktree)
// /status N читает живое состояние сессии через liveProbe-адаптер. // /status N читает живое состояние сессии через liveProbe-адаптер.
coreCtx.Live = &liveProbe{reg: live} coreCtx.Live = &liveProbe{reg: live}
@@ -226,6 +300,14 @@ func (a *App) Run(ctx context.Context) error {
// Авто-проверка обновления (только уведомление владельца; замена — по /update) // Авто-проверка обновления (только уведомление владельца; замена — по /update)
a.startAutoCheck(ctx) a.startAutoCheck(ctx)
// UI: окно блокирует главную горутину (спец 12.6). Кнопка «Завершить»
// отменяет ctx → fyneApp.Quit() → Run возвращается → graceful shutdown.
// Закрытие крестиком = сворачивание: Core продолжает работать.
if a.UI != nil {
a.UI.SetOnQuit(cancel)
return a.UI.Run(ctx)
}
// Ожидание сигнала или фатальной ошибки Telegram // Ожидание сигнала или фатальной ошибки Telegram
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@@ -313,6 +395,10 @@ func (a *App) handleIncoming(inc chat.Incoming) {
} }
a.send(ctx, uid, result.Reply) a.send(ctx, uid, result.Reply)
case isUISession(uid):
// Свободная UI-вкладка (нет активной задачи): свободный LLM-чат.
a.freeChatReply(ctx, uid, text)
default: default:
a.send(ctx, uid, "Нет активной задачи. /start — создать новую.") a.send(ctx, uid, "Нет активной задачи. /start — создать новую.")
} }
@@ -377,6 +463,26 @@ func hasArg(text string) bool {
return i > 0 && strings.TrimSpace(s[i:]) != "" return i > 0 && strings.TrimSpace(s[i:]) != ""
} }
// isUISession — true, если UserID принадлежит UI-вкладке-сессии (ui://sess-*).
func isUISession(uid chat.UserID) bool {
return strings.HasPrefix(string(uid), ui.SessionID)
}
// freeChatReply — ответ свободной LLM-сессии (вкладка UI без активной задачи).
func (a *App) freeChatReply(ctx context.Context, uid chat.UserID, text string) {
if a.FreeChat == nil {
a.send(ctx, uid, "Нет активной задачи. /start — создать новую.")
return
}
reply, err := a.FreeChat.Chat(ctx, uid, text)
if err != nil {
log.Printf("app: free chat %s: %v", uid, err)
a.send(ctx, uid, "Ошибка LLM-чата.")
return
}
a.send(ctx, uid, reply)
}
// ownerUID — куда слать авто-уведомления об обновлении. // ownerUID — куда слать авто-уведомления об обновлении.
func (a *App) ownerUID() chat.UserID { func (a *App) ownerUID() chat.UserID {
if a.Config.Telegram.ChatID != "" { if a.Config.Telegram.ChatID != "" {

View File

@@ -21,7 +21,7 @@ func TestNew(t *testing.T) {
t.Setenv("RATATOSKR_DB", dbPath) t.Setenv("RATATOSKR_DB", dbPath)
// Загружаем без config-файла (дефолты + env) // Загружаем без config-файла (дефолты + env)
a, err := New("", "dev", "") a, err := New("", "dev", "", true)
if err != nil { if err != nil {
t.Fatalf("New() err = %v", err) t.Fatalf("New() err = %v", err)
} }
@@ -52,7 +52,7 @@ func TestNew_MissingToken(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345") t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath) t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("", "dev", "") _, err := New("", "dev", "", true)
if err == nil { if err == nil {
t.Fatal("expected error for missing token") t.Fatal("expected error for missing token")
} }
@@ -67,7 +67,7 @@ func TestNew_BadDB(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345") t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath) t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("", "dev", "") _, err := New("", "dev", "", true)
if err == nil { if err == nil {
t.Fatal("expected error for invalid db path") t.Fatal("expected error for invalid db path")
} }
@@ -84,7 +84,7 @@ func TestNew_RunCtxCancel(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345") t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath) t.Setenv("RATATOSKR_DB", dbPath)
a, err := New("", "dev", "") a, err := New("", "dev", "", true)
if err != nil { if err != nil {
t.Fatalf("New() err = %v", err) t.Fatalf("New() err = %v", err)
} }
@@ -130,7 +130,7 @@ func TestNew_WorktreeCreated(t *testing.T) {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
a, err := New(configPath, "dev", "") a, err := New(configPath, "dev", "", true)
if err != nil { if err != nil {
t.Fatalf("New() err = %v", err) t.Fatalf("New() err = %v", err)
} }
@@ -175,7 +175,7 @@ func TestNew_UpdateWiring(t *testing.T) {
} }
// 1) base_url из update-блока, а НЕ git // 1) base_url из update-блока, а НЕ git
a, err := New(configPath, "dev", "") a, err := New(configPath, "dev", "", true)
if err != nil { if err != nil {
t.Fatalf("New() err = %v", err) t.Fatalf("New() err = %v", err)
} }
@@ -196,7 +196,7 @@ func TestNew_UpdateWiring(t *testing.T) {
} }
// 2) вшитый updateToken перекрывает конфиг // 2) вшитый updateToken перекрывает конфиг
a2, err := New(configPath, "dev", "embedded-update-token") a2, err := New(configPath, "dev", "embedded-update-token", true)
if err != nil { if err != nil {
t.Fatalf("New() err = %v", err) t.Fatalf("New() err = %v", err)
} }
@@ -248,4 +248,4 @@ func TestVersion_Semver(t *testing.T) {
if !re.MatchString(Version) { if !re.MatchString(Version) {
t.Errorf("Version = %q, ожидался формат major.minor.patch", Version) t.Errorf("Version = %q, ожидался формат major.minor.patch", Version)
} }
} }

15
internal/app/ui_cgo.go Normal file
View File

@@ -0,0 +1,15 @@
//go:build cgo
package app
import (
"github.com/kamelion/ratatoskr-go/internal/ui"
"github.com/kamelion/ratatoskr-go/internal/ui/desktop"
)
// newUIWindow создаёт десктопное окно (Fyne) на основе чтения-модели.
// Сборка с cgo: GLFW доступен, окно реально.
func newUIWindow(store ui.Store) ui.Window {
w := desktop.New("ratatoskr-go", store)
return w
}

11
internal/app/ui_noui.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build !cgo
package app
import (
"github.com/kamelion/ratatoskr-go/internal/ui"
)
// newUIWindow возвращает nil: без cgo (нет GLFW) окно недоступно,
// приложение работает в headless-режиме.
func newUIWindow(ui.Store) ui.Window { return nil }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/storage" "github.com/kamelion/ratatoskr-go/internal/storage"
) )
@@ -18,6 +19,27 @@ type Core struct {
MaxQuestionsPerTurn int MaxQuestionsPerTurn int
// Live — опциональный просмотр живой сессии задачи (для /status N). // Live — опциональный просмотр живой сессии задачи (для /status N).
Live LiveProber Live LiveProber
// Events — издатель доменных событий для UI. nil — события выключены.
Events events.Publisher
}
// publish отправляет доменное событие, если задан издатель.
func (c *Core) publish(e events.Event) {
if c.Events != nil {
c.Events.Publish(e)
}
}
// setStatus переводит задачу в новый статус: сохраняет в БД и публикует событие.
// from фиксируется до перехода (для TaskStatusChanged).
func (c *Core) setStatus(ctx context.Context, task *storage.Task, to storage.Status) error {
from := task.Status
task.Status = to
if err := c.Store.UpdateTask(ctx, task); err != nil {
return err
}
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: to})
return nil
} }
// LiveProber — абстракция за журналом живых сессий (реализация — *opencode.LiveRegistry). // LiveProber — абстракция за журналом живых сессий (реализация — *opencode.LiveRegistry).
@@ -117,11 +139,10 @@ func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
task.Status = storage.StatusCollecting if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
if err := c.Store.ClearHistory(ctx, task.ID); err != nil {
return Result{}, err return Result{}, err
} }
if err := c.Store.UpdateTask(ctx, task); err != nil { if err := c.Store.ClearHistory(ctx, task.ID); err != nil {
return Result{}, err return Result{}, err
} }
return Result{ return Result{
@@ -137,8 +158,7 @@ func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
task.Status = storage.StatusCancelled if err := c.setStatus(ctx, task, storage.StatusCancelled); err != nil {
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err return Result{}, err
} }
return Result{ return Result{
@@ -202,11 +222,10 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
Status: task.Status, Status: task.Status,
}, nil }, nil
} }
task.Status = storage.StatusCollecting if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
if err := c.Store.ClearHistory(ctx, id); err != nil {
return Result{}, err return Result{}, err
} }
if err := c.Store.UpdateTask(ctx, task); err != nil { if err := c.Store.ClearHistory(ctx, id); err != nil {
return Result{}, err return Result{}, err
} }
return Result{ return Result{
@@ -272,8 +291,7 @@ func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result,
// handleConsent одобряет задачу: ready → approved (финальное одобрение, // handleConsent одобряет задачу: ready → approved (финальное одобрение,
// после которого воркер забирает задачу на выполнение). // после которого воркер забирает задачу на выполнение).
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) { func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
task.Status = storage.StatusApproved if err := c.setStatus(ctx, task, storage.StatusApproved); err != nil {
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err return Result{}, err
} }
return Result{ return Result{
@@ -285,6 +303,7 @@ func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, e
// handleEdit — правка черновика в фазе ready → снова сбор + аналитик. // handleEdit — правка черновика в фазе ready → снова сбор + аналитик.
func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string) (Result, error) { func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string) (Result, error) {
from := task.Status
task.Status = storage.StatusCollecting task.Status = storage.StatusCollecting
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil { if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
return Result{}, err return Result{}, err
@@ -292,6 +311,7 @@ func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string)
if err := c.Store.UpdateTask(ctx, task); err != nil { if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err return Result{}, err
} }
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status})
return c.runDecide(ctx, task, false) return c.runDecide(ctx, task, false)
} }
@@ -300,10 +320,12 @@ func (c *Core) handleTurn(ctx context.Context, task *storage.Task, text string)
// переход draft→collecting нужно персистить до вызова аналитика, // переход draft→collecting нужно персистить до вызова аналитика,
// иначе propose сделает draft→ready (невалидно) // иначе propose сделает draft→ready (невалидно)
if task.Status != storage.StatusCollecting { if task.Status != storage.StatusCollecting {
from := task.Status
task.Status = storage.StatusCollecting task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil { if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err return Result{}, err
} }
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status})
} }
if text != "" { if text != "" {
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil { if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
@@ -348,8 +370,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
switch decision.Phase { switch decision.Phase {
case "abort": case "abort":
task.Status = storage.StatusAborted if err := c.setStatus(ctx, task, storage.StatusAborted); err != nil {
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err return Result{}, err
} }
reply := decision.ChatReply reply := decision.ChatReply
@@ -363,15 +384,13 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
applyDraft(task, decision.Draft) applyDraft(task, decision.Draft)
// E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить. // E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить.
if len(task.EffectiveRepos()) == 0 { if len(task.EffectiveRepos()) == 0 {
task.Status = storage.StatusCollecting if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err return Result{}, err
} }
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.") reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
} }
task.Status = storage.StatusReady if err := c.setStatus(ctx, task, storage.StatusReady); err != nil {
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err return Result{}, err
} }
return Result{ return Result{
@@ -382,8 +401,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
default: // "ask" default: // "ask"
applyDraft(task, decision.Draft) applyDraft(task, decision.Draft)
task.Status = storage.StatusCollecting if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err return Result{}, err
} }
reply := buildAskReply(decision, c.MaxQuestionsPerTurn) reply := buildAskReply(decision, c.MaxQuestionsPerTurn)

110
internal/events/bus.go Normal file
View File

@@ -0,0 +1,110 @@
// Package events — шина событий для обмена UI ↔ Core.
//
// Две независимые шины: доменная (статусы задач, история, трейсы) и логовая
// (сырые строки лога для панели «Логи»). Обе построены на одном типе *Bus,
// DOMAIN шина блокирующая (гарантия доставки и порядка), логовая — та же,
// но с большим буфером, чтобы не тормозить логирование.
package events
import (
"sync"
)
// Event — доменное событие. Закрытый интерфейс: новые типы добавляются
// только внутри пакета.
type Event interface {
_event()
}
// Bus — широковещательная шина событий (fan-out).
//
// Publish блокирует вызывающую горутину до тех пор, пока все подписчики не
// получат событие (в копию их буфера). Порядок событий для каждого
// подписчика сохраняется. Удаление подписчика происходит горутиной-монтируется
// close(done), что снимает блокировку Publish.
type Bus struct {
bufSize int
mu sync.RWMutex
subs map[*subscriber]struct{}
}
type subscriber struct {
ch chan Event
done chan struct{}
}
// New создаёт шину с буфером bufSize на каждого подписчика.
func New(bufSize int) *Bus {
if bufSize < 1 {
bufSize = 1
}
return &Bus{
bufSize: bufSize,
subs: make(map[*subscriber]struct{}),
}
}
// Subscribe регистрирует нового подписчика и возвращает канал событий вместе
// с функцией отписки. Рекомендуемый паттерн потребления:
//
// ch, unsub := bus.Subscribe()
// defer unsub()
// for {
// select {
// case e := <-ch:
// switch ev := e.(type) { ... }
// case <-closeCh:
// return
// }
// }
//
// Канал не закрывается шиной: выход из горутины подписчика организуется через
// закрытие канала приложения либо другого сигнала в select.
func (b *Bus) Subscribe() (<-chan Event, func()) {
s := &subscriber{
ch: make(chan Event, b.bufSize),
done: make(chan struct{}),
}
b.mu.Lock()
b.subs[s] = struct{}{}
b.mu.Unlock()
var once sync.Once
unsubscribe := func() {
once.Do(func() {
close(s.done)
b.mu.Lock()
delete(b.subs, s)
b.mu.Unlock()
})
}
return s.ch, unsubscribe
}
// Publish рассылает событие всем подписчикам и блокируется, пока каждый
// подписчик либо примет событие (в свой буфер), либо отпишется. Порядок
// рассылки стабилен (по списку подписок). Безопасен для Concurrent вызовов.
func (b *Bus) Publish(e Event) {
b.mu.RLock()
subs := make([]*subscriber, 0, len(b.subs))
for s := range b.subs {
subs = append(subs, s)
}
b.mu.RUnlock()
for _, s := range subs {
select {
case s.ch <- e:
case <-s.done:
}
}
}
// SubscribersCount — число активных подписчиков (для юнит-тестов и отладки).
func (b *Bus) SubscribersCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subs)
}

119
internal/events/bus_test.go Normal file
View File

@@ -0,0 +1,119 @@
package events
import (
"testing"
"time"
"github.com/kamelion/ratatoskr-go/internal/model"
)
// receiveOne помогает получить одно событие с таймаутом.
func receiveOne(t *testing.T, ch <-chan Event) Event {
t.Helper()
select {
case e := <-ch:
return e
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for event")
return nil
}
}
func TestBusPublishToSubscriber(t *testing.T) {
bus := New(10)
ch, unsub := bus.Subscribe()
defer unsub()
want := TaskStatusChanged{ID: 42, From: model.StatusReady, To: model.StatusRunning}
bus.Publish(want)
got := receiveOne(t, ch)
ev, ok := got.(TaskStatusChanged)
if !ok {
t.Fatalf("got %T, want TaskStatusChanged", got)
}
if ev.ID != 42 || ev.From != model.StatusReady || ev.To != model.StatusRunning {
t.Fatalf("unexpected event: %+v", ev)
}
}
func TestBusFanout(t *testing.T) {
bus := New(10)
ch1, unsub1 := bus.Subscribe()
defer unsub1()
ch2, unsub2 := bus.Subscribe()
defer unsub2()
e := LogLine{Level: "log", Text: "hello"}
bus.Publish(e)
if got := receiveOne(t, ch1); got != e {
t.Fatalf("subscriber 1 got %#v, want %#v", got, e)
}
if got := receiveOne(t, ch2); got != e {
t.Fatalf("subscriber 2 got %#v, want %#v", got, e)
}
}
func TestBusPreservesOrder(t *testing.T) {
bus := New(64)
ch, unsub := bus.Subscribe()
defer unsub()
const n = 25
for i := 0; i < n; i++ {
bus.Publish(TraceAppended{TaskID: int64(i)})
}
for i := 0; i < n; i++ {
ev := receiveOne(t, ch)
ta, ok := ev.(TraceAppended)
if !ok {
t.Fatalf("got %T, want TraceAppended", ev)
}
if ta.TaskID != int64(i) {
t.Fatalf("out of order: got %d, want %d", ta.TaskID, i)
}
}
}
func TestUnsubscribeStopsDelivery(t *testing.T) {
bus := New(10)
ch, unsub := bus.Subscribe()
bus.Publish(TaskCreated{ID: 1})
receiveOne(t, ch)
unsub()
if got := bus.SubscribersCount(); got != 0 {
t.Fatalf("SubscribersCount = %d, want 0", got)
}
// Убеждаемся, что Publish не блокируется навечно отписанным подписчиком.
bus.Publish(TaskCreated{ID: 2})
select {
case got := <-ch:
t.Fatalf("received %#v after unsubscribe", got)
case <-time.After(200 * time.Millisecond):
}
}
func TestBusSubscribersCount(t *testing.T) {
bus := New(10)
if got := bus.SubscribersCount(); got != 0 {
t.Fatalf("initial count = %d, want 0", got)
}
_, unsub1 := bus.Subscribe()
_, unsub2 := bus.Subscribe()
if got := bus.SubscribersCount(); got != 2 {
t.Fatalf("count = %d, want 2", got)
}
unsub1()
unsub2()
if got := bus.SubscribersCount(); got != 0 {
t.Fatalf("after unsub count = %d, want 0", got)
}
}
func TestNilPublisherNoOp(t *testing.T) {
NilPublisher{}.Publish(TaskCreated{ID: 1}) // must not panic
}

56
internal/events/events.go Normal file
View File

@@ -0,0 +1,56 @@
package events
import "github.com/kamelion/ratatoskr-go/internal/model"
// TaskCreated — создана новая задача.
type TaskCreated struct {
ID int64
ChatID string
Title string
}
// TaskUpdated — задача изменена (поля, права, репозитории).
type TaskUpdated struct {
ID int64
}
// TaskDeleted — задача удалена.
type TaskDeleted struct {
ID int64
}
// TaskStatusChanged — статус задачи изменился (переход из From в To).
type TaskStatusChanged struct {
ID int64
From model.Status
To model.Status
}
// HistoryAppended — добавлено сообщение в историю задачи.
type HistoryAppended struct {
TaskID int64
Role string // user | assistant | реплика события
Content string
}
// TraceAppended — добавлен/обновлён трейс субагента.
type TraceAppended struct {
TaskID int64
Agent string
Status model.TraceStatus
}
// AgentActivity — смена этапа работы агента (для анимации «состояния»).
type AgentActivity struct {
TaskID int64
Agent string
Stage string
}
func (TaskCreated) _event() {}
func (TaskUpdated) _event() {}
func (TaskDeleted) _event() {}
func (TaskStatusChanged) _event() {}
func (HistoryAppended) _event() {}
func (TraceAppended) _event() {}
func (AgentActivity) _event() {}

96
internal/events/hub.go Normal file
View File

@@ -0,0 +1,96 @@
package events
import (
"reflect"
"sync"
)
// Handler — колбэк-обработчик события конкретного типа.
type Handler[T Event] func(e T)
// Hub — типизированный подписчик шины.
//
// Слушает *Bus в собственной горутине и вызывает зарегистрированные обработчики
// для событий соответствующих типов. Порядок обработки сохраняется (порядок
// шины). Обработчики одного типа вызываются в порядке регистрации.
//
// Это мост между шиной и UI-потоком: колбэки выполняются в горутине Hub, поэтому
// внутри них нужно либо перекладывать работу на поток Fyne (fyne.Do), либо
// пользоваться только thread-safe структурами (binding).
type Hub struct {
bus *Bus
done chan struct{}
once sync.Once
ch <-chan Event
unsub func()
mu sync.Mutex
handlers map[reflect.Type][]any
}
// NewHub создаёт подписчик на указанную шину (пока не запущен).
func NewHub(bus *Bus) *Hub {
return &Hub{
bus: bus,
done: make(chan struct{}),
handlers: make(map[reflect.Type][]any),
}
}
// On регистрирует обработчик для типа события T. Безопасно вызывать до Start
// и из других горутин.
func On[T Event](h *Hub, fn Handler[T]) {
h.mu.Lock()
defer h.mu.Unlock()
var zero T
typ := reflect.TypeOf(zero)
h.handlers[typ] = append(h.handlers[typ], fn)
}
// Start подписывается на шину и запускает горутину чтения событий.
func (h *Hub) Start() {
if h.ch != nil {
return
}
h.ch, h.unsub = h.bus.Subscribe()
go h.run()
}
// Close останавливает горутину и отписывается от шины. Идемпотентен.
func (h *Hub) Close() {
h.once.Do(func() {
close(h.done)
if h.unsub != nil {
h.unsub()
}
})
}
// run — цикл чтения событий и диспетчеризации.
func (h *Hub) run() {
defer h.Close()
for {
select {
case e, ok := <-h.ch:
if !ok {
return
}
h.dispatch(e)
case <-h.done:
return
}
}
}
// dispatch вызывает все обработчики, зарегистрированные для типа события e.
func (h *Hub) dispatch(e Event) {
typ := reflect.TypeOf(e)
h.mu.Lock()
fns := append([]any(nil), h.handlers[typ]...)
h.mu.Unlock()
for _, fn := range fns {
reflect.ValueOf(fn).Call([]reflect.Value{reflect.ValueOf(e)})
}
}

119
internal/events/hub_test.go Normal file
View File

@@ -0,0 +1,119 @@
package events
import (
"sync/atomic"
"testing"
"time"
"github.com/kamelion/ratatoskr-go/internal/model"
)
func TestHubDeliversTypedEvent(t *testing.T) {
bus := New(16)
h := NewHub(bus)
var got atomic.Value
On[TaskStatusChanged](h, func(e TaskStatusChanged) {
got.CompareAndSwap(nil, e)
})
h.Start()
defer h.Close()
want := TaskStatusChanged{ID: 7, From: model.StatusReady, To: model.StatusRunning}
bus.Publish(want)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if raw := got.Load(); raw != nil {
ev := raw.(TaskStatusChanged)
if ev.ID != 7 || ev.From != model.StatusReady || ev.To != model.StatusRunning {
t.Fatalf("unexpected event: %+v", ev)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("handler was not called")
}
func TestHubIgnoresUnrelatedTypes(t *testing.T) {
bus := New(16)
h := NewHub(bus)
var calls atomic.Int32
On[TaskStatusChanged](h, func(TaskStatusChanged) { calls.Add(1) })
h.Start()
defer h.Close()
// другие типы событий не должны дойти до этого обработчика
bus.Publish(AgentActivity{TaskID: 1, Agent: "dev", Stage: "run"})
bus.Publish(LogLine{Level: "log", Text: "x"})
time.Sleep(200 * time.Millisecond)
if n := calls.Load(); n != 0 {
t.Fatalf("handler called %d times for unrelated events", n)
}
}
func TestHubMultipleHandlersSameType(t *testing.T) {
bus := New(16)
h := NewHub(bus)
var a, b atomic.Int32
On[HistoryAppended](h, func(HistoryAppended) { a.Add(1) })
On[HistoryAppended](h, func(HistoryAppended) { b.Add(1) })
h.Start()
defer h.Close()
bus.Publish(HistoryAppended{TaskID: 1, Role: "user", Content: "hi"})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if a.Load() == 1 && b.Load() == 1 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("not all handlers called")
}
func TestHubCloseStopsDelivery(t *testing.T) {
bus := New(16)
h := NewHub(bus)
var calls atomic.Int32
On[TaskCreated](h, func(TaskCreated) { calls.Add(1) })
h.Start()
bus.Publish(TaskCreated{ID: 1})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && calls.Load() == 0 {
time.Sleep(10 * time.Millisecond)
}
if calls.Load() == 0 {
t.Fatal("initial delivery failed")
}
h.Close()
// после Close Hub отписан — события не доходят
bus.Publish(TaskCreated{ID: 2})
time.Sleep(150 * time.Millisecond)
if n := calls.Load(); n > 1 {
t.Fatalf("handler called %d times after Close", n)
}
}
func TestHubStartIdempotent(t *testing.T) {
bus := New(16)
h := NewHub(bus)
h.Start()
h.Start() // повторный Start не должен создавать вторую горутину
h.Close()
// если бы было две горутины — Publish блокировался бы на буфере, но закрытие сняло бы блок;
// просто проверяем, что Close и повторный Start не падают
bus.Publish(TaskCreated{ID: 1})
}

105
internal/events/log.go Normal file
View File

@@ -0,0 +1,105 @@
package events
import (
"strings"
"sync"
)
// Канонические уровни записей лога (фильтр панели «Логи», см. ui.LogLevels).
const (
LevelError = "error"
LevelWarning = "warning"
LevelInfo = "info"
LevelDebug = "debug"
)
// LogLine — строка лога для панели «Логи».
type LogLine struct {
Level string
Text string
}
func (LogLine) _event() {}
// markers проверяет наличие любого из маркеров в уже приведённой к нижнему
// регистру строке.
func markers(s string, anyOf ...string) bool {
for _, m := range anyOf {
if strings.Contains(s, m) {
return true
}
}
return false
}
// ClassifyLevel определяет уровень строки лога по её тексту.
//
// Стандартный log.Printf не несёт уровня, поэтому применяются эвристики по
// маркерам в тексте (в приоритете debug → warning → error): "debug"/"trace" —
// отладка; "warn"/"предупреж" — предупреждение; "error"/"ошибк"/"failed"/
// "fatal"/"panic" — ошибка. Остальные строки — информационные.
func ClassifyLevel(line string) string {
s := strings.ToLower(line)
switch {
case markers(s, "debug", "trace"):
return LevelDebug
case markers(s, "warn", "предупреж"):
return LevelWarning
case markers(s, "error", "ошибк", "failed", "failure", "fatal", "panic"):
return LevelError
default:
return LevelInfo
}
}
// LogBus — тип-обёртка над *Bus для логов.
//
// Логи идут отдельной шиной, чтобы большие объёмы текста не блокировали
// доменные события и наоборот.
type LogBus struct {
*Bus
}
// NewLogBus создаёт шину логов с буфером на подписчика.
func NewLogBus(bufSize int) *LogBus {
return &LogBus{Bus: New(bufSize)}
}
// LogWriter — io.Writer, который публикует каждую строку лога как LogLine.
// Предполагается использование через log.SetOutput в связке, чтобы всё
// логирование приложения попадало и в панель «Логи».
type LogWriter struct {
bus *Bus
mu sync.Mutex // защищает остаток частичной строки
buf strings.Builder
}
// NewLogWriter создаёт LogWriter, публикующий строки лога как события
// LogLine; уровень каждой строки определяется эвристикой ClassifyLevel.
func NewLogWriter(bus *LogBus) *LogWriter {
return &LogWriter{bus: bus.Bus}
}
// Write реализует io.Writer. Данные разрезаются по переводам строки:
// каждая законченная строка публикуется отдельным событием.
func (w *LogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.buf.Write(p)
data := w.buf.String()
for {
idx := strings.IndexByte(data, '\n')
if idx < 0 {
break
}
line := strings.TrimSuffix(data[:idx], "\r")
data = data[idx+1:]
if line != "" {
w.bus.Publish(LogLine{Level: ClassifyLevel(line), Text: line})
}
}
w.buf.Reset()
w.buf.WriteString(data)
return len(p), nil
}

122
internal/events/log_test.go Normal file
View File

@@ -0,0 +1,122 @@
package events
import (
"strings"
"testing"
"time"
)
func TestLogWriterLines(t *testing.T) {
lbus := NewLogBus(16)
ch, unsub := lbus.Subscribe()
defer unsub()
w := NewLogWriter(lbus)
if _, err := w.Write([]byte("first line\nsecond line\n")); err != nil {
t.Fatalf("Write: %v", err)
}
first := receiveOne(t, ch)
ll, ok := first.(LogLine)
if !ok {
t.Fatalf("got %T, want LogLine", first)
}
// строка без маркеров ошибок/отладки — информационная
if ll.Text != "first line" || ll.Level != LevelInfo {
t.Fatalf("unexpected first line: %+v", ll)
}
second := receiveOne(t, ch)
if ll, ok := second.(LogLine); !ok || ll.Text != "second line" {
t.Fatalf("unexpected second line: %+v", second)
}
}
func TestLogWriterPartialLine(t *testing.T) {
lbus := NewLogBus(16)
ch, unsub := lbus.Subscribe()
defer unsub()
w := NewLogWriter(lbus)
if _, err := w.Write([]byte("partial")); err != nil {
t.Fatalf("Write: %v", err)
}
select {
case got := <-ch:
t.Fatalf("partial line should not be published yet, got %#v", got)
case <-time.After(150 * time.Millisecond):
}
if _, err := w.Write([]byte(" line\n")); err != nil {
t.Fatalf("Write: %v", err)
}
got := receiveOne(t, ch)
ll, ok := got.(LogLine)
if !ok || ll.Text != "partial line" {
t.Fatalf("unexpected: %#v", got)
}
}
func TestLogWriterMultiSplit(t *testing.T) {
lbus := NewLogBus(16)
ch, unsub := lbus.Subscribe()
defer unsub()
w := NewLogWriter(lbus)
if _, err := w.Write([]byte("line1\nline2\nline3\n")); err != nil {
t.Fatalf("Write: %v", err)
}
var texts []string
for i := 0; i < 3; i++ {
ev := receiveOne(t, ch)
texts = append(texts, ev.(LogLine).Text)
}
if strings.Join(texts, ",") != "line1,line2,line3" {
t.Fatalf("got %v", texts)
}
}
// ClassifyLevel определяет уровень строки по маркерам в тексте.
func TestClassifyLevel(t *testing.T) {
cases := []struct {
line string
want string
}{
{"app: db opened /tmp/r.db", LevelInfo},
{"app: worker started", LevelInfo},
{"opencode: debug: poll request", LevelDebug},
{"trace: session resumed", LevelDebug},
{"tg: warn: long poll timeout", LevelWarning},
{"ПРЕДУПРЕЖДЕНИЕ: конфиг не задан", LevelWarning},
{"app: process /start #5: run failed: exit status 1", LevelError},
{"ERROR: connection refused", LevelError},
{"worker: job failed after 3 retries", LevelError},
{"panic: runtime error", LevelError},
}
for _, c := range cases {
if got := ClassifyLevel(c.line); got != c.want {
t.Errorf("ClassifyLevel(%q) = %q, want %q", c.line, got, c.want)
}
}
}
// LogWriter публикует уровень, определённый по тексту строки.
func TestLogWriterClassifiesLevels(t *testing.T) {
lbus := NewLogBus(16)
ch, unsub := lbus.Subscribe()
defer unsub()
w := NewLogWriter(lbus)
if _, err := w.Write([]byte("all good\nERROR: boom\n")); err != nil {
t.Fatalf("Write: %v", err)
}
first := receiveOne(t, ch).(LogLine)
if first.Level != LevelInfo || first.Text != "all good" {
t.Fatalf("unexpected first: %+v", first)
}
second := receiveOne(t, ch).(LogLine)
if second.Level != LevelError || second.Text != "ERROR: boom" {
t.Fatalf("unexpected second: %+v", second)
}
}

View File

@@ -0,0 +1,20 @@
package events
// Publisher — минимальный интерфейс для встраивания шины в Core/Worker/Analyst.
//
// Core публикует доменные события через него; конкретная шина подставляется
// при сборке приложения. На время тестов или до создания UI можно
// использовать NilPublisher — безопасную no-op реализацию.
type Publisher interface {
Publish(e Event)
}
// NilPublisher — no-op издатель, чтобы компоненты могли работать без UI.
type NilPublisher struct{}
// Publish ничего не делает (совместимо с Publisher).
func (NilPublisher) Publish(Event) {}
// CompileTime-проверка: *Bus реализует Publisher.
var _ Publisher = (*Bus)(nil)
var _ Publisher = NilPublisher{}

77
internal/model/status.go Normal file
View File

@@ -0,0 +1,77 @@
// Package model — доменные типы и контракты Ratatoskr.
//
// Нижний слой архитектуры: не зависит ни от БД (storage), ни от UI (events).
// Хранит семантику предметной области (статусы задач, машина переходов),
// чтобы storage и events зависели только от этой модели, а не друг от друга.
package model
// Status — статус задачи (state machine).
type Status string
const (
StatusDraft Status = "draft" // только что создана
StatusCollecting Status = "collecting" // аналитик собирает детали
StatusReady Status = "ready" // черновик готов, ждёт одобрения пользователя
StatusApproved Status = "approved" // пользователь одобрил («создавай») — воркер берёт в работу
StatusRunning Status = "running" // opencode работает
StatusSuccess Status = "success" // задача выполнена
StatusFailed Status = "failed" // ошибка выполнения
StatusTimeout Status = "timeout" // таймаут opencode
StatusCancelled Status = "cancelled" // отменена пользователем
StatusAborted Status = "aborted" // сбой сбора, черновик выброшен
StatusClosed Status = "closed" // закрыта вручную
)
// AllStatuses — все возможные статусы для валидации.
var AllStatuses = []Status{
StatusDraft, StatusCollecting, StatusReady, StatusApproved,
StatusRunning, StatusSuccess, StatusFailed, StatusTimeout,
StatusCancelled, StatusAborted, StatusClosed,
}
// validTransitions задаёт разрешённые переходы статусов.
var validTransitions = map[Status][]Status{
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
// ready — черновик готов: «создавай» → approved, либо правка/отмена/закрытие.
StatusReady: {StatusApproved, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting},
// approved — финальное одобрение: воркер берёт в running, либо отмена/сбой/закрытие.
StatusApproved: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed},
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
StatusSuccess: {StatusClosed},
StatusFailed: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusTimeout: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusCancelled: {StatusClosed},
StatusAborted: {StatusClosed},
StatusClosed: {}, // терминальный
}
// IsValidTransition проверяет, допустим ли переход from → to.
func IsValidTransition(from, to Status) bool {
allowed, ok := validTransitions[from]
if !ok {
return false
}
for _, s := range allowed {
if s == to {
return true
}
}
return false
}
// IsTerminal возвращает true, если статус терминальный.
func IsTerminal(s Status) bool {
return s == StatusSuccess || s == StatusCancelled ||
s == StatusAborted || s == StatusClosed
}
// TraceStatus — статус трассировки субагента.
type TraceStatus string
const (
TraceRunning TraceStatus = "running"
TraceSuccess TraceStatus = "success"
TraceFailed TraceStatus = "failed"
TraceTimeout TraceStatus = "timeout"
)

View File

@@ -0,0 +1,50 @@
package model
import "testing"
func TestValidTransitions(t *testing.T) {
cases := []struct {
from, to Status
want bool
}{
{StatusDraft, StatusCollecting, true},
{StatusDraft, StatusRunning, false},
{StatusReady, StatusApproved, true},
{StatusApproved, StatusRunning, true},
{StatusRunning, StatusSuccess, true},
{StatusRunning, StatusClosed, false},
{StatusSuccess, StatusClosed, true},
{StatusClosed, StatusDraft, false},
}
for _, c := range cases {
if got := IsValidTransition(c.from, c.to); got != c.want {
t.Errorf("IsValidTransition(%q, %q) = %v, want %v", c.from, c.to, got, c.want)
}
}
}
func TestIsTerminal(t *testing.T) {
terminal := []Status{StatusSuccess, StatusCancelled, StatusAborted, StatusClosed}
for _, s := range terminal {
if !IsTerminal(s) {
t.Errorf("IsTerminal(%q) = false, want true", s)
}
}
nonTerminal := []Status{StatusDraft, StatusReady, StatusRunning, StatusCollecting, StatusApproved, StatusFailed, StatusTimeout}
for _, s := range nonTerminal {
if IsTerminal(s) {
t.Errorf("IsTerminal(%q) = true, want false", s)
}
}
}
func TestAllStatusesCoverage(t *testing.T) {
seen := make(map[Status]bool)
for _, s := range AllStatuses {
seen[s] = true
}
if len(seen) != 11 {
t.Fatalf("AllStatuses has %d unique statuses, want 11", len(seen))
}
}

View File

@@ -1,66 +1,43 @@
package storage package storage
import "encoding/json" import (
"encoding/json"
// Status — статус задачи (state machine). "github.com/kamelion/ratatoskr-go/internal/model"
type Status string )
// Status — алиас доменного статуса задачи.
//
// Совместимый мост: весь внешний код продолжает использовать storage.Status
// (например «storage.StatusRunning»), но единый источник истины — model.Status.
type Status = model.Status
// Статусы задачи — re-export из model.
const ( const (
StatusDraft Status = "draft" // только что создана StatusDraft = model.StatusDraft
StatusCollecting Status = "collecting" // аналитик собирает детали StatusCollecting = model.StatusCollecting
StatusReady Status = "ready" // черновик готов, ждёт одобрения пользователя StatusReady = model.StatusReady
StatusApproved Status = "approved" // пользователь одобрил («создавай») — воркер берёт в работу StatusApproved = model.StatusApproved
StatusRunning Status = "running" // opencode работает StatusRunning = model.StatusRunning
StatusSuccess Status = "success" // задача выполнена StatusSuccess = model.StatusSuccess
StatusFailed Status = "failed" // ошибка выполнения StatusFailed = model.StatusFailed
StatusTimeout Status = "timeout" // таймаут opencode StatusTimeout = model.StatusTimeout
StatusCancelled Status = "cancelled" // отменена пользователем StatusCancelled = model.StatusCancelled
StatusAborted Status = "aborted" // сбой сбора, черновик выброшен StatusAborted = model.StatusAborted
StatusClosed Status = "closed" // закрыта вручную StatusClosed = model.StatusClosed
) )
// AllStatuses — все возможные статусы для валидации. // AllStatuses — все возможные статусы для валидации.
var AllStatuses = []Status{ var AllStatuses = model.AllStatuses
StatusDraft, StatusCollecting, StatusReady, StatusApproved,
StatusRunning, StatusSuccess, StatusFailed, StatusTimeout,
StatusCancelled, StatusAborted, StatusClosed,
}
// validTransitions задаёт разрешённые переходы статусов.
var validTransitions = map[Status][]Status{
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
// ready — черновик готов: «создавай» → approved, либо правка/отмена/закрытие.
StatusReady: {StatusApproved, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting},
// approved — финальное одобрение: воркер берёт в running, либо отмена/сбой/закрытие.
StatusApproved: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed},
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
StatusSuccess: {StatusClosed},
StatusFailed: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusTimeout: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusCancelled: {StatusClosed},
StatusAborted: {StatusClosed},
StatusClosed: {}, // терминальный
}
// IsValidTransition проверяет, допустим ли переход from → to. // IsValidTransition проверяет, допустим ли переход from → to.
func IsValidTransition(from, to Status) bool { func IsValidTransition(from, to Status) bool {
allowed, ok := validTransitions[from] return model.IsValidTransition(from, to)
if !ok {
return false
}
for _, s := range allowed {
if s == to {
return true
}
}
return false
} }
// IsTerminal возвращает true, если статус терминальный. // IsTerminal возвращает true, если статус терминальный.
func IsTerminal(s Status) bool { func IsTerminal(s Status) bool {
return s == StatusSuccess || s == StatusCancelled || return model.IsTerminal(s)
s == StatusAborted || s == StatusClosed
} }
// Task — запись задачи в БД. // Task — запись задачи в БД.
@@ -111,14 +88,14 @@ func (t *Task) SetReposFromDB(repos string) {
_ = json.Unmarshal([]byte(repos), &t.Repos) _ = json.Unmarshal([]byte(repos), &t.Repos)
} }
// Trace — запись трассировки выполнения. // TraceStatus — алиас доменного статуса трассировки.
type TraceStatus string type TraceStatus = model.TraceStatus
const ( const (
TraceRunning TraceStatus = "running" TraceRunning TraceStatus = model.TraceRunning
TraceSuccess TraceStatus = "success" TraceSuccess TraceStatus = model.TraceSuccess
TraceFailed TraceStatus = "failed" TraceFailed TraceStatus = model.TraceFailed
TraceTimeout TraceStatus = "timeout" TraceTimeout TraceStatus = model.TraceTimeout
) )
// Trace — лог одного субагента. // Trace — лог одного субагента.

39
internal/ui/chat_panel.go Normal file
View File

@@ -0,0 +1,39 @@
package ui
// ChatPanel — панель «Диалог» (спец 12.12): транскрипт диалога выбранной
// задачи + композитор (поле ввода и кнопки команд).
//
// Транскрипт — буфер ограниченного размера; при превышении лимита старые
// строки отбрасываются. Композитор подключается к ui.Commands через
// SetCommands. Обновление — на потоке Fyne; вызывающий уже внутри fyne.Do.
type ChatPanel interface {
// SetTranscript заменяет содержимое диалога полным рендером.
SetTranscript(text string)
// Append добавляет строку в диалог (буфер ограничен).
Append(text string)
// Clear очищает диалог (задача недоступна/не выбрана).
Clear()
// SetCommands подключает команды к композитору (ввод + кнопки).
SetCommands(c *Commands)
}
// NilChatPanel — no-op реализация ChatPanel для headless-режима и тестов.
type NilChatPanel struct{}
// NewNilChatPanel создаёт NilChatPanel.
func NewNilChatPanel() *NilChatPanel { return &NilChatPanel{} }
func (NilChatPanel) SetTranscript(string) {}
func (NilChatPanel) Append(string) {}
func (NilChatPanel) Clear() {}
func (NilChatPanel) SetCommands(*Commands) {}
// FormatRole — строка роли в диалоге (pure-функция, спец 12.12).
func FormatRole(role string) string {
switch role {
case "user":
return "👤 "
default:
return "🤖 "
}
}

View File

@@ -0,0 +1,26 @@
package ui
import "testing"
// FormatRole — строка роли в диалоге (спец 12.12), pure-функция без Fyne.
func TestFormatRole(t *testing.T) {
if FormatRole("user") != "👤 " {
t.Errorf("user: got %q, want «👤 »", FormatRole("user"))
}
if FormatRole("assistant") != "🤖 " {
t.Errorf("assistant: got %q, want «🤖 »", FormatRole("assistant"))
}
if FormatRole("") != "🤖 " {
t.Errorf("default: got %q, want «🤖 »", FormatRole(""))
}
}
// NilChatPanel — no-op контракт (спец 12.8): безопасно для headless/тестов.
func TestNilChatPanelNoop(t *testing.T) {
p := NewNilChatPanel()
c := NewCommands(nil)
p.SetTranscript("история")
p.Append("строка")
p.Clear()
p.SetCommands(c)
}

46
internal/ui/commands.go Normal file
View File

@@ -0,0 +1,46 @@
package ui
import "fmt"
// Commands — отображение действий UI в текстовые команды канала.
//
// UI — это chat.Channel, поэтому все действия пользователя (кнопки, ввод)
// превращаются в текстовые сообщения, которые уходят в Router → Core. Это
// сохраняет единый путь команд (спец 12.2): никаких прямых вызовов Core из
// виджетов — только текст через канал.
type Commands struct {
// Submit отправляет текст пользователя в канал (обычно — window.Submit).
Submit func(text string)
}
// NewCommands создаёт Commands с отправкой через fn.
func NewCommands(fn func(text string)) *Commands {
if fn == nil {
fn = func(string) {}
}
return &Commands{Submit: fn}
}
// Start — новая задача (/start).
func (c *Commands) Start() { c.Submit("/start") }
// Cancel — отмена текущей задачи (/cancel).
func (c *Commands) Cancel() { c.Submit("/cancel") }
// Skip — пропустить сбор, сформировать черновик (/skip).
func (c *Commands) Skip() { c.Submit("/skip") }
// Retry — перезапустить задачу N (/retry N).
func (c *Commands) Retry(id int64) { c.Submit(fmt.Sprintf("/retry %d", id)) }
// Status — запросить статус задачи N (/status N).
func (c *Commands) Status(id int64) { c.Submit(fmt.Sprintf("/status %d", id)) }
// Continue — продолжить задачу N (/continue N).
func (c *Commands) Continue(id int64) { c.Submit(fmt.Sprintf("/continue %d", id)) }
// Approve — одобрить черновик («создавай»).
func (c *Commands) Approve() { c.Submit("создавай") }
// SendText — обычное сообщение пользователя (ввод в поле).
func (c *Commands) SendText(text string) { c.Submit(text) }

52
internal/ui/controller.go Normal file
View File

@@ -0,0 +1,52 @@
package ui
import (
"github.com/kamelion/ratatoskr-go/internal/events"
)
// Controller — подписчик шин событий, мост к Fyne-представлению.
//
// Регистрирует Hub'ы на доменной и логовой шинах и диспатчит события в View
// (см. спец 12.4): колбэки View выполняются в горутинах Hub, поэтому
// реализация Fyne должна вызывать fyne.Do, чтобы обновить виджеты на главной
// горутине окна.
//
// Жизненный цикл независим от ядра: Close только отписывает Controller от шин
// (например, при сворачивании окна), но Core/worker продолжают работать.
type Controller struct {
domain *events.Hub
logs *events.Hub
view View
}
// New создаёт Controller, подписанный на доменную (domainBus) и логовую
// (logBus) шины, с представлением view.
func New(domainBus *events.Bus, logBus *events.LogBus, view View) *Controller {
c := &Controller{view: view}
c.domain = events.NewHub(domainBus)
events.On(c.domain, func(e events.TaskCreated) { view.OnTaskCreated(e) })
events.On(c.domain, func(e events.TaskUpdated) { view.OnTaskUpdated(e) })
events.On(c.domain, func(e events.TaskDeleted) { view.OnTaskDeleted(e) })
events.On(c.domain, func(e events.TaskStatusChanged) { view.OnTaskStatusChanged(e) })
events.On(c.domain, func(e events.HistoryAppended) { view.OnHistoryAppended(e) })
events.On(c.domain, func(e events.TraceAppended) { view.OnTraceAppended(e) })
events.On(c.domain, func(e events.AgentActivity) { view.OnAgentActivity(e) })
c.logs = events.NewHub(logBus.Bus)
events.On(c.logs, func(e events.LogLine) { view.OnLog(e) })
return c
}
// Start запускает подписку на обе шины.
func (c *Controller) Start() {
c.domain.Start()
c.logs.Start()
}
// Close останавливает подписки. Идемпотентен.
func (c *Controller) Close() {
c.domain.Close()
c.logs.Close()
}

View File

@@ -0,0 +1,146 @@
package ui
import (
"sync"
"testing"
"time"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/model"
)
// recordingView фиксирует события, дошедшие до View.
type recordingView struct {
mu sync.Mutex
created int
updated int
deleted int
status []events.TaskStatusChanged
history []events.HistoryAppended
traces int
act []events.AgentActivity
logs []events.LogLine
}
func (v *recordingView) OnTaskCreated(events.TaskCreated) { v.created++ }
func (v *recordingView) OnTaskUpdated(events.TaskUpdated) { v.updated++ }
func (v *recordingView) OnTaskDeleted(events.TaskDeleted) { v.deleted++ }
func (v *recordingView) OnHistoryAppended(e events.HistoryAppended) {
v.mu.Lock()
v.history = append(v.history, e)
v.mu.Unlock()
}
func (v *recordingView) OnTraceAppended(events.TraceAppended) {
v.mu.Lock()
v.traces++
v.mu.Unlock()
}
func (v *recordingView) OnTaskStatusChanged(e events.TaskStatusChanged) {
v.mu.Lock()
v.status = append(v.status, e)
v.mu.Unlock()
}
func (v *recordingView) OnAgentActivity(e events.AgentActivity) {
v.mu.Lock()
v.act = append(v.act, e)
v.mu.Unlock()
}
func (v *recordingView) OnLog(e events.LogLine) {
v.mu.Lock()
v.logs = append(v.logs, e)
v.mu.Unlock()
}
// waitFor ждёт, пока условие не станет истинным (до 2 секунд).
func waitFor(cond func() bool) bool {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return cond()
}
func TestControllerDeliversDomainEvents(t *testing.T) {
dbus := events.New(64)
lbus := events.NewLogBus(64)
view := &recordingView{}
c := New(dbus, lbus, view)
c.Start()
defer c.Close()
dbus.Publish(events.TaskStatusChanged{ID: 1, From: model.StatusReady, To: model.StatusRunning})
dbus.Publish(events.HistoryAppended{TaskID: 1, Role: "user", Content: "hello"})
dbus.Publish(events.AgentActivity{TaskID: 1, Agent: "dev", Stage: "run"})
if !waitFor(func() bool { return len(view.status) == 1 }) {
t.Fatal("TaskStatusChanged not delivered")
}
if !waitFor(func() bool { return len(view.history) == 1 }) {
t.Fatal("HistoryAppended not delivered")
}
if !waitFor(func() bool { return len(view.act) == 1 }) {
t.Fatal("AgentActivity not delivered")
}
sc := view.status[0]
if sc.ID != 1 || sc.From != model.StatusReady || sc.To != model.StatusRunning {
t.Fatalf("bad status event: %+v", sc)
}
}
func TestControllerDeliversLogEvents(t *testing.T) {
dbus := events.New(64)
lbus := events.NewLogBus(64)
view := &recordingView{}
c := New(dbus, lbus, view)
c.Start()
defer c.Close()
lbus.Bus.Publish(events.LogLine{Level: "log", Text: "worker started"})
if !waitFor(func() bool { return len(view.logs) == 1 }) {
t.Fatal("LogLine not delivered")
}
if got := view.logs[0]; got.Text != "worker started" || got.Level != "log" {
t.Fatalf("bad log event: %+v", got)
}
}
func TestControllerCloseStopsDelivery(t *testing.T) {
dbus := events.New(64)
lbus := events.NewLogBus(64)
view := &recordingView{}
c := New(dbus, lbus, view)
c.Start()
dbus.Publish(events.TaskCreated{ID: 1})
if !waitFor(func() bool { return view.created == 1 }) {
t.Fatal("initial delivery failed")
}
c.Close()
dbus.Publish(events.TaskCreated{ID: 2})
lbus.Bus.Publish(events.LogLine{Level: "log", Text: "after close"})
time.Sleep(150 * time.Millisecond)
if view.created > 1 {
t.Fatalf("TaskCreated delivered %d times after Close", view.created)
}
if len(view.logs) > 0 {
t.Fatalf("LogLine delivered %d times after Close", len(view.logs))
}
}
func TestControllerIdempotentClose(t *testing.T) {
dbus := events.New(16)
lbus := events.NewLogBus(16)
c := New(dbus, lbus, &recordingView{})
c.Start()
c.Close()
c.Close() // не должно падать
}

View File

@@ -0,0 +1,87 @@
//go:build cgo
package desktop
import (
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/kamelion/ratatoskr-go/internal/ui"
)
// chatLimit — обрезка буфера транскрипта диалога (спец 12.12).
const chatLimit = 200_000
// ChatPanel — Fyne-реализация ui.ChatPanel (спец 12.8, 12.12): транскрипт
// диалога + композитор (поле ввода и кнопки команд).
type ChatPanel struct {
convLbl *widget.Label
input *widget.Entry
commands *ui.Commands
}
// NewChatPanel создаёт ChatPanel.
func NewChatPanel() *ChatPanel {
p := &ChatPanel{}
p.convLbl = widget.NewLabel("Выберите задачу.")
p.convLbl.Wrapping = fyne.TextWrapWord
p.input = widget.NewEntry()
p.input.SetPlaceHolder("Сообщение… (Enter — отправить)")
return p
}
// Transcript возвращает виджет транскрипта (Scroll) для встраивания в окно.
func (p *ChatPanel) Transcript() fyne.CanvasObject {
return container.NewScroll(p.convLbl)
}
// Composer возвращает виджет композитора (ввод + кнопки команд).
func (p *ChatPanel) Composer() fyne.CanvasObject {
cmdBar := container.NewHBox(
widget.NewButton("Новая", func() { if p.commands != nil { p.commands.Start() } }),
widget.NewButton("Создавай", func() { if p.commands != nil { p.commands.Approve() } }),
widget.NewButton("Пропустить", func() { if p.commands != nil { p.commands.Skip() } }),
widget.NewButton("Отмена", func() { if p.commands != nil { p.commands.Cancel() } }),
)
return container.NewBorder(nil, nil, cmdBar, nil, p.input)
}
// SetTranscript заменяет содержимое диалога (спец 12.12).
func (p *ChatPanel) SetTranscript(text string) {
p.convLbl.SetText(text)
}
// Append добавляет строку в диалог (буфер ограничен).
func (p *ChatPanel) Append(text string) {
s := p.convLbl.Text + text + "\n\n"
if len(s) > chatLimit {
s = s[len(s)-chatLimit:]
}
p.convLbl.SetText(s)
}
// Clear очищает диалог.
func (p *ChatPanel) Clear() {
p.convLbl.SetText("")
}
// SetCommands подключает команды к композитору.
func (p *ChatPanel) SetCommands(c *ui.Commands) {
p.commands = c
p.input.OnSubmitted = func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
p.input.SetText("")
if c != nil {
c.SendText(s)
}
}
}
// compile-time проверка реализации контракта.
var _ ui.ChatPanel = (*ChatPanel)(nil)

View File

@@ -0,0 +1,193 @@
//go:build cgo
package desktop
import (
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/kamelion/ratatoskr-go/internal/ui"
)
// maxLogBytes — кап суммарного объёма текста буфера «Логи» (байты), чтобы
// не расти бесконечно и не копить мегабайты в памяти.
const maxLogBytes = 10 * 1024 * 1024
// renderFlushMs — дебаунс-интервал перерисовки ленты (мс): при всплеске
// логов рендер выполняется не на каждую запись, а раз в этот интервал.
const renderFlushMs = 200
// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных
// логов с фильтром по уровням (error/warning/info/debug).
//
// Записи хранятся в ui.LogBuffer (ограниченный буфер); лента показывает
// только записи уровней, отмеченных в фильтре (по умолчанию отмечены все).
// Фильтр — выпадающий список с чекбоксами: кнопка над лентой открывает
// widget.PopUp с widget.Check по каждому уровню; изменение набора сразу
// перерисовывает ленту — и по уже загруженным записям, и для новых.
//
// Состояние (buf/selected/pending/flushScheduled/lastFlush) доступается только
// с потока Fyne: Append приходит из горутин Hub и заворачивается в fyne.Do,
// колбэки чекбоксов Fyne вызывает сам на своём потоке. Перерисовка ленты
// коалесцируется (см. scheduleFlush), т.к. строка ленты пересобирается
// целиком и при капе в 10MB на каждый append была бы дорогой.
type LogPanel struct {
label *widget.Label
btn *widget.Button // кнопка-дропдаун фильтра уровней
buf *ui.LogBuffer // буфер записей, старые вытесняются
selected map[string]bool // уровень → показывать (по умолчанию все)
pending bool // буфер изменился, рендер отложен (поток Fyne)
flushScheduled bool // фоновый флаш уже запущен (поток Fyne)
lastFlush int64 // мс последнего рендера (поток Fyne)
}
// NewLogPanel создаёт LogPanel с уровнями error/warning/info (debug скрыт).
func NewLogPanel() *LogPanel {
p := &LogPanel{
buf: ui.NewLogBuffer(maxLogBytes),
selected: make(map[string]bool, len(ui.LogLevels)),
}
for _, lvl := range ui.LogLevels {
p.selected[lvl] = lvl != ui.LogLevelDebug
}
p.label = widget.NewLabel("")
p.label.Wrapping = fyne.TextWrapWord
p.btn = widget.NewButton(p.filterCaption(), p.showFilterPopUp)
return p
}
// Widget возвращает контейнер панели для встраивания в окно (таб «Логи»):
// строка фильтра сверху, лента лога в скролле снизу.
func (p *LogPanel) Widget() fyne.CanvasObject {
return container.NewBorder(
container.NewHBox(p.btn), nil, nil, nil,
container.NewScroll(p.label),
)
}
// Append добавляет запись лога (спец 12.9), с ограничением буфера.
// Вызывается из горутин Hub → вся работа на потоке Fyne через fyne.Do.
func (p *LogPanel) Append(level, text string) {
fyne.Do(func() {
lvl := ui.NormalizeLogLevel(level)
dropped := p.buf.Append(lvl, text)
// Перерисовка нужна, если запись видима или вытеснение задело ленту.
if p.selected[lvl] || dropped {
p.pending = true
p.scheduleFlush()
}
})
}
// scheduleFlush планирует отложенный рендер через renderFlushMs (или сразу,
// если с прошлого рендера прошло больше интервала). Все вызовы — с потока
// Fyne; фоновый поток только спит и по таймеру отправляет flush через fyne.Do,
// поэтому защита от повторного планирования (flushScheduled) работает без
// блокировок.
func (p *LogPanel) scheduleFlush() {
if p.flushScheduled {
return
}
p.flushScheduled = true
delay := int64(renderFlushMs) - (time.Now().UnixMilli() - p.lastFlush)
if delay < 1 {
delay = 1
}
go func() {
time.Sleep(time.Duration(delay) * time.Millisecond)
fyne.Do(p.flush)
}()
}
// flush выполняет отложенную перерисовку ленты (на потоке Fyne).
func (p *LogPanel) flush() {
p.flushScheduled = false
if p.pending {
p.pending = false
p.lastFlush = time.Now().UnixMilli()
p.render()
}
}
// render перерисовывает ленту: дописывает в label только новые видимые
// записи (RenderNew). Если вытеснение задело уже выведенные записи (stale),
// RenderNew возвращает полный текст — пересобираем label целиком. Так при
// росте ленты мимо лимита флаш дешёвый (только дельта), а полная пересборка
// происходит лишь по вытеснению/смене фильтра.
func (p *LogPanel) render() {
delta, full := p.buf.RenderNew(p.selected)
if full {
p.label.SetText(delta)
return
}
if delta == "" {
return
}
cur := p.label.Text
if cur == "" {
p.label.SetText(delta)
} else {
p.label.SetText(cur + delta)
}
}
// setLevel включает/выключает показ уровня и сразу обновляет ленту.
// Вызывается только с потока Fyne (колбэки чекбоксов). Смена набора уровней
// меняет видимость уже выведенных записей, поэтому — полный пересбор.
func (p *LogPanel) setLevel(level string, on bool) {
if p.selected[level] == on {
return
}
p.selected[level] = on
p.btn.SetText(p.filterCaption())
p.label.SetText(p.buf.Render(p.selected))
p.buf.ResetRendered()
}
// filterCaption — подпись кнопки-дропдауна: текущий выбранный набор уровней.
func (p *LogPanel) filterCaption() string {
var chosen []string
for _, lvl := range ui.LogLevels {
if p.selected[lvl] {
chosen = append(chosen, lvl)
}
}
switch {
case len(chosen) == len(ui.LogLevels):
return "Уровни: все"
case len(chosen) == 0:
return "Уровни: нет"
default:
return "Уровни: " + strings.Join(chosen, "+")
}
}
// showFilterPopUp открывает выпадающий список с чекбоксами уровней под
// кнопкой фильтра. Список пересобирается при каждом открытии — чекбоксы
// всегда отражают актуальное состояние фильтра.
func (p *LogPanel) showFilterPopUp() {
c := fyne.CurrentApp().Driver().CanvasForObject(p.btn)
if c == nil {
return // виджет ещё не встроен в окно
}
rows := make([]fyne.CanvasObject, 0, len(ui.LogLevels))
for _, lvl := range ui.LogLevels {
lvl := lvl
cb := widget.NewCheck(lvl, func(on bool) { p.setLevel(lvl, on) })
cb.SetChecked(p.selected[lvl])
rows = append(rows, cb)
}
pop := widget.NewPopUp(container.NewPadded(container.NewVBox(rows...)), c)
pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(p.btn)
pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+p.btn.Size().Height))
}
// compile-time проверка реализации контракта.
var _ ui.LogPanel = (*LogPanel)(nil)

View File

@@ -0,0 +1,48 @@
//go:build cgo
package desktop
import (
"os"
"syscall"
"unsafe"
)
// Нативный максимум окна через Win32.
//
// Fyne (v2.6) не даёт ни Move, ни Maximize у fyne.Window, поэтому разворот на
// экран (над панелью задач) делаем напрямую через user32: находим HWND нашего
// процесса через EnumWindows и вызываем ShowWindow(SW_MAXIMIZE).
// Только Windows (платформа приложения по спеке — Windows).
const (
swMaximize = 9
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
procEnumWindows = user32.NewProc("EnumWindows")
procGetWindowThreadProcID = user32.NewProc("GetWindowThreadProcessId")
procIsWindowVisible = user32.NewProc("IsWindowVisible")
procShowWindow = user32.NewProc("ShowWindow")
)
func maximizeWindow() {
pid := uint32(os.Getpid())
cb := syscall.NewCallback(func(hwnd syscall.Handle, _ uintptr) uintptr {
var winPid uint32
procGetWindowThreadProcID.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&winPid)))
if winPid != pid {
return 1 // continue перечисление
}
visible, _, _ := procIsWindowVisible.Call(uintptr(hwnd))
if visible == 0 {
return 1 // continue перечисление
}
procShowWindow.Call(uintptr(hwnd), swMaximize)
return 0 // остановить перечисление
})
procEnumWindows.Call(cb, 0)
}

View File

@@ -0,0 +1,50 @@
//go:build cgo
package desktop
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/kamelion/ratatoskr-go/internal/ui"
)
// stateLimit — обрезка буфера панели «Состояние» (спец 12.13).
const stateLimit = 200_000
// StatePanel — Fyne-реализация ui.StatePanel (спец 12.8, 12.13): агенты и
// трейсы выбранной задачи (таб «Состояние»).
type StatePanel struct {
stateLbl *widget.Label
}
// NewStatePanel создаёт StatePanel.
func NewStatePanel() *StatePanel {
p := &StatePanel{}
p.stateLbl = widget.NewLabel("")
p.stateLbl.Wrapping = fyne.TextWrapWord
return p
}
// Widget возвращает контейнер панели для встраивания в таб «Состояние».
func (p *StatePanel) Widget() fyne.CanvasObject {
return container.NewScroll(p.stateLbl)
}
// SetText заменяет содержимое снимком трасс (спец 12.13).
func (p *StatePanel) SetText(text string) {
p.stateLbl.SetText(text)
}
// Append добавляет live-строку (буфер ограничен).
func (p *StatePanel) Append(text string) {
s := p.stateLbl.Text + text + "\n"
if len(s) > stateLimit {
s = s[len(s)-stateLimit:]
}
p.stateLbl.SetText(s)
}
// compile-time проверка реализации контракта.
var _ ui.StatePanel = (*StatePanel)(nil)

View File

@@ -0,0 +1,54 @@
//go:build cgo
package desktop
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/kamelion/ratatoskr-go/internal/storage"
"github.com/kamelion/ratatoskr-go/internal/ui"
)
// TaskDetailPanel — Fyne-реализация ui.TaskDetailPanel (спец 12.8, 12.11):
// детали выбранной задачи (заголовок, статус, детали) — верх right-сплита.
type TaskDetailPanel struct {
titleLbl *widget.Label
statusLbl *widget.Label
detailLbl *widget.Label
}
// NewTaskDetailPanel создаёт TaskDetailPanel.
func NewTaskDetailPanel() *TaskDetailPanel {
p := &TaskDetailPanel{}
p.titleLbl = widget.NewLabel("—")
p.titleLbl.TextStyle = fyne.TextStyle{Bold: true}
p.statusLbl = widget.NewLabel("")
p.statusLbl.TextStyle = fyne.TextStyle{Italic: true}
p.detailLbl = widget.NewLabel("")
p.detailLbl.Wrapping = fyne.TextWrapWord
return p
}
// Widget возвращает контейнер панели для встраивания в окно (верх right-сплита).
func (p *TaskDetailPanel) Widget() fyne.CanvasObject {
return container.NewVBox(p.titleLbl, p.statusLbl, p.detailLbl)
}
// ShowTask рендерит детали из снимка задачи (спец 12.11).
func (p *TaskDetailPanel) ShowTask(t storage.Task) {
p.titleLbl.SetText(t.Title)
p.statusLbl.SetText(string(t.Status))
p.detailLbl.SetText(ui.TaskDetailText(t))
}
// ShowEmpty сбрасывает к placeholder при недоступной задаче.
func (p *TaskDetailPanel) ShowEmpty() {
p.titleLbl.SetText("—")
p.statusLbl.SetText("")
p.detailLbl.SetText("")
}
// compile-time проверка реализации контракта.
var _ ui.TaskDetailPanel = (*TaskDetailPanel)(nil)

View File

@@ -0,0 +1,78 @@
//go:build cgo
package desktop
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
"github.com/kamelion/ratatoskr-go/internal/storage"
"github.com/kamelion/ratatoskr-go/internal/ui"
)
// TaskListPanel — Fyne-реализация ui.TaskListPanel (спец 12.8, 12.10): список
// задач (левая панель окна).
//
// Внутри — widget.List над собственным буфером []storage.Task (снимки из Store).
// Состояние выбора живёт в окне: окно зовёт Select, чтобы подсветить строку.
type TaskListPanel struct {
list *widget.List
tasks []storage.Task
}
// NewTaskListPanel создаёт TaskListPanel.
func NewTaskListPanel() *TaskListPanel {
p := &TaskListPanel{}
p.list = widget.NewList(
func() int { return len(p.tasks) },
func() fyne.CanvasObject {
return widget.NewLabel("loading…")
},
func(id widget.ListItemID, o fyne.CanvasObject) {
if id < 0 || id >= len(p.tasks) {
return
}
o.(*widget.Label).SetText(ui.TaskTitle(p.tasks[id]))
},
)
return p
}
// Widget возвращает виджет списка для встраивания в окно (левая панель).
func (p *TaskListPanel) Widget() fyne.CanvasObject {
return p.list
}
// SetTasks заменяет снимок списка и перерисовывает виджет (спец 12.10).
func (p *TaskListPanel) SetTasks(tasks []storage.Task) {
p.tasks = tasks
p.list.Refresh()
}
// Select подсвечивает строку задачи id; если её нет — снимает выделение.
func (p *TaskListPanel) Select(id int64) {
idx := -1
for i, t := range p.tasks {
if t.ID == id {
idx = i
break
}
}
if idx < 0 {
p.list.UnselectAll()
return
}
p.list.Select(widget.ListItemID(idx))
}
// OnSelect регистрирует колбэк клика по строке (спец 12.10).
func (p *TaskListPanel) OnSelect(fn func(id int64)) {
p.list.OnSelected = func(id widget.ListItemID) {
if fn != nil && id >= 0 && id < len(p.tasks) {
fn(p.tasks[id].ID)
}
}
}
// compile-time проверка реализации контракта.
var _ ui.TaskListPanel = (*TaskListPanel)(nil)

View File

@@ -0,0 +1,452 @@
//go:build cgo
// Package desktop — Fyne-реализация окна UI (см. internal/ui).
//
// Сборка требует cgo (GLFW). На машинах без C-компилятора пакет не входит
// в сборку, и app работает в headless-режиме (newUIWindow → nil).
package desktop
import (
"context"
"fmt"
"log"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/kamelion/ratatoskr-go/internal/chat"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/ui"
)
const (
selectedPref = "task.selected"
splitHPref = "split.h"
)
// Window — Fyne-окно: chat.Channel + ui.View.
//
// Внутри — вкладки-сессии (ui.Session): каждая вкладка — независимый диалог
// с LLM (свой UserID/Address, свой активный task). Свободные вкладки (без
// задачи) создаются кнопкой «Новый чат»; вкладки задач открываются из списка.
type Window struct {
fyneApp fyne.App
win fyne.Window
store ui.Store
handler chat.Handler
// состояние выбора (живёт в окне, спец 12.10)
selected int64
// виджеты
taskList *TaskListPanel
detail *TaskDetailPanel
state *StatePanel
logs *LogPanel
// вкладки-сессии
tabs *container.AppTabs
sessions map[chat.Address]*ui.Session // Address вкладки → сессия
panels map[chat.Address]*ChatPanel // Address вкладки → панель
tabSession map[*container.TabItem]*ui.Session
nextSess int // счётчик уникальных ui://sess-<n>
hsplit *container.Split
onQuit func()
}
// New создаёт окно. appID — идентификатор Fyne-приложения (Preferences).
func New(appID string, store ui.Store) *Window {
fa := app.NewWithID(appID)
w := &Window{
fyneApp: fa,
store: store,
sessions: map[chat.Address]*ui.Session{},
panels: map[chat.Address]*ChatPanel{},
tabSession: map[*container.TabItem]*ui.Session{},
}
w.win = fa.NewWindow("Ratatoskr")
w.build()
w.win.SetCloseIntercept(func() {
// Закрытие окна = сворачивание (спец 12.6): Core продолжает работать.
w.saveLayout()
w.win.Hide()
})
fa.Settings().SetTheme(theme.DarkTheme())
return w
}
// SetOnQuit задаёт колбэк полного выхода (кнопка «Завершить»).
func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn }
// build собирает виджеты и раскладку.
func (w *Window) build() {
w.detail = NewTaskDetailPanel()
w.state = NewStatePanel()
w.logs = NewLogPanel()
// Список задач (слева)
w.taskList = NewTaskListPanel()
w.taskList.OnSelect(func(id int64) { w.openTaskTab(id) })
quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() {
w.saveLayout()
if w.onQuit != nil {
w.onQuit()
}
})
left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget())
// Центр: вкладки-сессии + кнопка «Новый чат».
w.tabs = container.NewAppTabs()
w.tabs.OnSelected = func(tab *container.TabItem) {
// фокус вкладки → показываем детали/состояние её задачи
if s := w.tabSession[tab]; s != nil && s.BoundTask > 0 {
w.selectTask(s.BoundTask)
}
}
newBtn := widget.NewButtonWithIcon("Новый чат", theme.ContentAddIcon(), func() {
w.addFreeTab()
})
center := container.NewBorder(newBtn, nil, nil, nil, w.tabs)
// Нижняя панель: вкладки «Логи» + «Состояние».
bottomTabs := container.NewAppTabs(
container.NewTabItem("Логи", w.logs.Widget()),
container.NewTabItem("Состояние", w.state.Widget()),
)
bottom := container.NewBorder(nil, nil, nil, nil, bottomTabs)
w.hsplit = container.NewHSplit(left, container.NewVSplit(center, bottom))
w.win.SetContent(w.hsplit)
// Восстановление layout из Preferences.
pref := w.fyneApp.Preferences()
w.hsplit.SetOffset(pref.FloatWithFallback(splitHPref, 0.30))
// Начальный снимок списка.
w.refreshList()
// Открываем свободную вкладку-сессию по умолчанию.
w.addFreeTab()
}
// addFreeTab создаёт свободную вкладку-сессию (in-memory диалог с LLM).
func (w *Window) addFreeTab() {
w.nextSess++
id := fmt.Sprintf("%s%d", ui.SessionID, w.nextSess)
w.addTab(ui.NewFreeSession(chat.UserID(id), chat.Address(id)))
}
// addTab создаёт вкладку для сессии и фокусирует её.
func (w *Window) addTab(sess *ui.Session) {
panel := NewChatPanel()
panel.SetCommands(ui.NewCommands(func(text string) {
w.submitText(sess.UserID, sess.Address, text)
}))
content := container.NewBorder(nil, panel.Composer(), nil, nil, panel.Transcript())
tab := container.NewTabItem(sess.Title, content)
w.tabs.Append(tab)
w.sessions[sess.Address] = sess
w.panels[sess.Address] = panel
w.tabSession[tab] = sess
w.tabs.Select(tab)
}
// tabForAddress возвращает вкладку по адресу сессии.
func (w *Window) tabForAddress(addr chat.Address) *container.TabItem {
for tab, s := range w.tabSession {
if s.Address == addr {
return tab
}
}
return nil
}
// openTaskTab открывает вкладку задачи (или фокусирует уже открытую).
//
// Вкладку можно открыть только для задачи, созданной в UI-сессии (ChatID с
// префиксом ui://sess-*): её ChatID уникален и не пересекается с Telegram-каналом.
// Задачи Telegram не открываются в отдельной вкладке — их показывает список и
// детали (selectTask), чтобы не плодить сессию с общим UserID=чат-id.
func (w *Window) openTaskTab(id int64) {
ctx := context.Background()
t, err := w.store.GetTask(ctx, id)
if err != nil {
return
}
// Telegram-задача (ChatID без ui://sess-*) — только детали, без вкладки.
if !strings.HasPrefix(t.ChatID, ui.SessionID) {
w.selectTask(id)
return
}
// Если вкладка для задачи уже есть — фокусируем.
for _, s := range w.sessions {
if s.BoundTask == id {
if tab := w.tabForAddress(s.Address); tab != nil {
w.tabs.Select(tab)
return
}
}
}
// Создаём новую вкладку: UserID == task.ChatID (чат задачи уникален).
w.nextSess++
addr := chat.Address(fmt.Sprintf("%s%d", ui.SessionID, w.nextSess))
sess := ui.NewTaskSession(addr, t)
w.addTab(sess)
// Транскрипт из истории задачи.
if panel := w.panels[sess.Address]; panel != nil {
panel.SetTranscript(w.renderHistory(ctx, id))
}
w.selectTask(id)
}
// renderHistory строит транскрипт диалога задачи (история + краткие трассы).
func (w *Window) renderHistory(ctx context.Context, id int64) string {
hist, err := w.store.GetHistory(ctx, id)
if err != nil {
hist = nil
}
var b strings.Builder
for _, h := range hist {
b.WriteString(ui.FormatRole(h.Role))
b.WriteString(h.Content)
b.WriteString("\n\n")
}
traces, err := w.store.GetTraces(ctx, id)
if err == nil {
for _, tr := range traces {
b.WriteString("— " + tr.Agent + " (" + string(tr.Status) + ")\n")
}
}
if b.Len() == 0 {
b.WriteString("Нет сообщений. /start — начать задачу.")
}
return b.String()
}
// saveLayout сохраняет текущие позиции сплитов в Preferences.
// Вызывается при сворачивании окна и перед полным выходом.
func (w *Window) saveLayout() {
pref := w.fyneApp.Preferences()
pref.SetFloat(splitHPref, w.hsplit.Offset)
}
// selectTask загружает детали выбранной задачи (снимки из Store).
func (w *Window) selectTask(id int64) {
w.selected = id
w.taskList.Select(id)
w.fyneApp.Preferences().SetInt(selectedPref, int(id))
ctx := context.Background()
t, err := w.store.GetTask(ctx, id)
if err != nil {
w.detail.ShowEmpty()
w.state.SetText(ui.StateText(nil))
return
}
w.detail.ShowTask(t)
w.refreshState(ctx, id)
}
// refreshState — снимок «Состояния» выбранной задачи (агенты + трейсы).
func (w *Window) refreshState(ctx context.Context, id int64) {
traces, err := w.store.GetTraces(ctx, id)
if err != nil {
return
}
w.state.SetText(ui.StateText(traces))
}
// refreshList перечитывает список задач из Store (снимок).
func (w *Window) refreshList() {
ctx := context.Background()
tasks, err := w.store.ListTasks(ctx)
if err != nil {
log.Printf("ui: list tasks: %v", err)
return
}
w.taskList.SetTasks(tasks)
w.taskList.Select(w.selected)
}
// appendLog добавляет запись в панель «Логи» (делегирует в LogPanel).
func (w *Window) appendLog(level, text string) {
w.logs.Append(level, text)
}
// submitText отправляет ввод пользователя из вкладки в Router.
func (w *Window) submitText(uid chat.UserID, addr chat.Address, text string) {
if w.handler != nil {
w.handler(chat.Incoming{
UserID: uid,
Address: addr,
Channel: w,
Msg: chat.Message{Text: text},
})
}
}
// bindTaskSession связывает свободную вкладку с созданной задачей: когда
// /start в свободном табе создал задачу с ChatID == UserID этой вкладки,
// вкладка становится task-сессией (спец: каждая задача — своя вкладка).
func (w *Window) bindTaskSession(chatID string, taskID int64) {
for addr, s := range w.sessions {
if string(s.UserID) != chatID || s.BoundTask != 0 {
continue
}
s.BoundTask = taskID
if tab := w.tabForAddress(addr); tab != nil {
tab.Text = "Задача #" + ui.Itoa(taskID)
w.tabs.Refresh()
}
}
}
// ---- chat.Channel ----
// OnMessage регистрирует обработчик входящих (Router).
func (w *Window) OnMessage(h chat.Handler) { w.handler = h }
// Run показывает окно и запускает Fyne-цикл. Блокирует до Quit.
func (w *Window) Run(ctx context.Context) error {
w.win.Show()
maximizeWindow()
go func() {
<-ctx.Done()
w.fyneApp.Quit()
}()
w.fyneApp.Run()
return nil
}
// Send доставляет сообщение Core в диалог вкладки по её Address.
func (w *Window) Send(_ context.Context, to chat.Address, m chat.Message) error {
panel, ok := w.panels[to]
if !ok {
return nil
}
text := m.Text
if len(m.Options) > 0 {
var sb strings.Builder
sb.WriteString(text)
for _, opt := range m.Options {
sb.WriteString("\n")
sb.WriteString(opt.Label)
}
text = sb.String()
}
fyne.Do(func() { panel.Append("🤖 " + text) })
return nil
}
// Ask — вопрос с вариантами (отображается в диалоге вкладки).
func (w *Window) Ask(_ context.Context, to chat.Address, m chat.Message) error {
panel, ok := w.panels[to]
if !ok {
return nil
}
text := m.Text
for _, opt := range m.Options {
text += "\n" + opt.Label
}
fyne.Do(func() { panel.Append("❓ " + text) })
return nil
}
// Close завершает Fyne-цикл.
func (w *Window) Close() error {
w.fyneApp.Quit()
return nil
}
// ---- ui.View ----
// Все колбэки выполняются в горутинах events.Hub → обновляем UI через fyne.Do.
func (w *Window) OnTaskCreated(e events.TaskCreated) {
fyne.Do(func() {
w.refreshList()
// /start в свободной вкладке: вкладка привязывается к задаче.
w.bindTaskSession(e.ChatID, e.ID)
if w.selected == 0 {
w.selectTask(e.ID)
}
})
}
func (w *Window) OnTaskUpdated(e events.TaskUpdated) {
fyne.Do(func() {
w.refreshList()
if w.selected == e.ID {
w.selectTask(e.ID)
}
})
}
func (w *Window) OnTaskDeleted(e events.TaskDeleted) {
fyne.Do(func() {
if w.selected == e.ID {
w.selected = 0
}
w.refreshList()
})
}
func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) {
fyne.Do(func() {
w.refreshList()
if w.selected == e.ID {
w.selectTask(e.ID)
}
w.state.Append(ui.TaskStatusText(e))
})
}
func (w *Window) OnHistoryAppended(e events.HistoryAppended) {
fyne.Do(func() {
// история приходит в диалог вкладки задачи
for addr, s := range w.sessions {
if s.BoundTask == e.TaskID {
if panel, ok := w.panels[addr]; ok {
panel.Append(ui.FormatRole(e.Role) + e.Content)
}
}
}
})
}
func (w *Window) OnTraceAppended(e events.TraceAppended) {
fyne.Do(func() {
w.state.Append(ui.TraceLine(e.Agent, e.Status))
})
}
func (w *Window) OnAgentActivity(e events.AgentActivity) {
fyne.Do(func() {
w.state.Append(ui.ActivityLine(e.Agent, e.Stage))
})
}
func (w *Window) OnLog(e events.LogLine) {
w.appendLog(e.Level, e.Text)
}
// compile-time проверка: окно реализует chat.Channel и ui.View.
var (
_ chat.Channel = (*Window)(nil)
_ ui.View = (*Window)(nil)
)

226
internal/ui/log_panel.go Normal file
View File

@@ -0,0 +1,226 @@
// Package ui — контроллер и представления десктопного интерфейса (Fyne).
//
// Слой обмена с ядром: однонаправленный поток (спец 12.212.5).
// - Core мутирует состояние; UI только читает снимки и реагирует на события.
// - Действия UI = команды (CreateTask/ApproveTask/...), которые зовут Core.
// - Подписчик шины (Controller) получает события и перекладывает их в View
// (реализация Fyne) через fyne.Do — никаких прямых вызовов Fyne из core.
//
// Контракты компонентов-виджетов (спец 12.8): интерфейс + Nil-реализация.
package ui
import "strings"
// Канонические уровни записей лога для фильтра панели «Логи» (спец 12.9).
const (
LogLevelError = "error"
LogLevelWarning = "warning"
LogLevelInfo = "info"
LogLevelDebug = "debug"
)
// LogLevels — уровни в порядке их вывода в фильтре виджета «Логи».
var LogLevels = []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
// NormalizeLogLevel приводит уровень записи лога к одному из канонических
// уровней (LogLevels). Синонимы маппятся на ближайший уровень (err/fatal →
// error, warn → warning, trace → debug); пустые и неизвестные значения
// ("log" от events.LogWriter и всё прочее) считаются информационными.
func NormalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case LogLevelError, "err", "fatal":
return LogLevelError
case LogLevelWarning, "warn":
return LogLevelWarning
case LogLevelDebug, "trace":
return LogLevelDebug
default:
return LogLevelInfo
}
}
// LogPanel — панель «Логи» (спец 12.9): поток системных логов с уровнями.
//
// Хранит записи (уровень + строка) в буфере ограниченного размера; при
// превышении лимита старые записи отбрасываются. Реализация обязана
// поддерживать фильтр по уровням: выпадающий список с чекбоксами
// (error/warning/info/debug), по умолчанию отмечены все уровни; показываются
// только записи выбранных уровней, изменение набора сразу обновляет ленту.
// Контейнер виджета отдаётся окну для встраивания в таб «Логи» — реализация
// (Fyne) сама владеет своим виджетом.
type LogPanel interface {
// Append добавляет запись лога: level — уровень из events.LogLine
// (нормализуется через NormalizeLogLevel), text — строка лога.
// Реализация обязана перекладывать работу на поток Fyne (fyne.Do /
// fyne.DoAndWait), т.к. вызывается из горутин Hub.
Append(level, text string)
}
// NilLogPanel — no-op реализация LogPanel для headless-режима и тестов.
type NilLogPanel struct{}
// NewNilLogPanel создаёт NilLogPanel.
func NewNilLogPanel() *NilLogPanel { return &NilLogPanel{} }
func (NilLogPanel) Append(string, string) {}
// LogEntry — запись буфера панели «Логи»: канонический уровень + строка.
type LogEntry struct {
Level string
Text string
}
// compactThreshold — порог, при котором «мёртвые» (вытесненные сдвигом)
// записи физически убираются из буфера пачкой. Вытеснение лениво: оно лишь
// инкрементирует start, поэтому не копирует хвост массива на каждую
// вытесненную запись (иначе trim был бы O(n²)). Уборка выполняется редко.
const compactThreshold = 256
// LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням
// (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией
// LogPanel и тестируется в internal/ui.
//
// Не синхронизирована: потокобезопасность обеспечивает владелец (все вызовы
// на потоке Fyne).
type LogBuffer struct {
entries []LogEntry
start int // индекс первого «живого» элемента (ленивое вытеснение)
total int // суммарный объём текста живых записей [start..], байты
maxLen int // лимит суммарного объёма текста записей, байты
// Инкрементальная отрисовка (RenderNew): rendered — сколько живых записей
// от начала уже выведено в текст ленты; stale — вытеснение задело уже
// выведенные записи, так что текст ленты нельзя дополнить дельтом и нужен
// полный пересбор. Эти поля поддерживаются только рендером.
rendered int
stale bool
}
// NewLogBuffer создаёт буфер ёмкостью maxLen байт текста записей.
func NewLogBuffer(maxLen int) *LogBuffer {
return &LogBuffer{maxLen: maxLen}
}
// Append добавляет запись (уровень нормализуется через NormalizeLogLevel,
// хвостовые переводы строк срезаются). При превышении лимита старые записи
// вытесняются. Сообщает, было ли вытеснение/обрезка — владелец должен
// перерисовать ленту, даже если новая запись не видима.
func (b *LogBuffer) Append(level, text string) bool {
b.entries = append(b.entries, LogEntry{
Level: NormalizeLogLevel(level),
Text: strings.TrimRight(text, "\r\n"),
})
b.total += len(b.entries[len(b.entries)-1].Text) + 1
return b.trim()
}
// trim удерживает суммарный объём текста живых записей в пределах maxLen,
// лениво вытесняя старые (инкремент start); одиночная запись длиннее лимита
// обрезается до последних maxLen байт. Сообщает, было ли вытеснение/обрезка.
func (b *LogBuffer) trim() bool {
dropped := false
live := len(b.entries) - b.start
evicted := 0
for b.total > b.maxLen && live > 1 {
b.total -= len(b.entries[b.start].Text) + 1
b.start++
live--
evicted++
dropped = true
}
// Если вытеснена хотя бы одна уже выведенная запись (b.rendered), текст
// ленты устарел: дельта не может убрать верхние строки, нужен полный
// пересбор. rendered при этом уменьшается на число вытесненных.
if evicted > 0 && b.rendered > 0 {
b.stale = true
}
if evicted >= b.rendered {
b.rendered = 0
} else {
b.rendered -= evicted
}
// Компакция «мёртвых» записей пачкой, а не на каждом append.
if b.start >= compactThreshold {
b.entries = b.entries[b.start:]
b.start = 0
}
if n := len(b.entries); n > b.start && len(b.entries[n-1].Text) > b.maxLen {
// Обрезка последней записи меняет уже выведенный текст, если она
// была отрисована.
if b.rendered == n-b.start {
b.stale = true
}
b.entries[n-1].Text = b.entries[n-1].Text[len(b.entries[n-1].Text)-b.maxLen:]
b.total = b.maxLen + 1
dropped = true
}
return dropped
}
// Entries возвращает копию списка живых записей буфера.
func (b *LogBuffer) Entries() []LogEntry {
out := make([]LogEntry, len(b.entries)-b.start)
copy(out, b.entries[b.start:])
return out
}
// Render строит текст ленты только из живых записей уровней, отмеченных в
// selected (уровень → показывать). Пустой или nil набор → пустая лента.
// Полный рендер: используется для первичной отрисовки и после смены фильтра.
func (b *LogBuffer) Render(selected map[string]bool) string {
if selected == nil {
return ""
}
return b.renderFrom(b.start, selected)
}
// ResetRendered помечает все живые записи как уже выведенные. Зовётся после
// полного рендера (SetText(Render)), чтобы следующий RenderNew вернул только
// новые записи.
func (b *LogBuffer) ResetRendered() {
b.rendered = len(b.entries) - b.start
b.stale = false
}
// RenderNew отдаёт текст ещё не выведенных видимых записей и флаг, требует
// ли слой полного пересбора ленты. Если вытеснение задело уже выведенные
// записи (stale), возвращаемый текст — полный рендер с нуля (и флаг true), а
// не дельта. Иначе — только текст новых записей, которым можно дополнить
// текущий текст ленты.
func (b *LogBuffer) RenderNew(selected map[string]bool) (text string, full bool) {
if selected == nil {
return "", false
}
live := len(b.entries) - b.start
if b.stale {
b.stale = false
b.rendered = live
return b.renderFrom(b.start, selected), true
}
from := b.start + b.rendered
var s strings.Builder
for i := from; i < len(b.entries); i++ {
e := b.entries[i]
if !selected[e.Level] {
continue
}
s.WriteString(e.Text)
s.WriteByte('\n')
}
b.rendered = live
return s.String(), false
}
// renderFrom строит текст записей, начиная с индекса from, с учётом selected.
func (b *LogBuffer) renderFrom(from int, selected map[string]bool) string {
var s strings.Builder
for i := from; i < len(b.entries); i++ {
e := b.entries[i]
if !selected[e.Level] {
continue
}
s.WriteString(e.Text)
s.WriteByte('\n')
}
return s.String()
}

View File

@@ -0,0 +1,320 @@
package ui
import "testing"
// NilLogPanel — no-op контракт (спец 12.8): Append ничего не делает, безопасно
// вызывается из горутин Hub и из тестов без Fyne.
func TestNilLogPanelAppendNoop(t *testing.T) {
p := NewNilLogPanel()
p.Append(LogLevelError, "line 1")
p.Append("", "")
p.Append(LogLevelDebug, "line 3")
}
// NormalizeLogLevel приводит любые уровни к каноническим четырём:
// синонимы маппятся, неизвестные/пустые значения считаются info.
func TestNormalizeLogLevel(t *testing.T) {
cases := []struct {
in string
want string
}{
{LogLevelError, LogLevelError},
{"ERROR", LogLevelError},
{" err ", LogLevelError},
{"fatal", LogLevelError},
{LogLevelWarning, LogLevelWarning},
{"WARN", LogLevelWarning},
{"Warning", LogLevelWarning},
{LogLevelInfo, LogLevelInfo},
{"INFO", LogLevelInfo},
{LogLevelDebug, LogLevelDebug},
{"DEBUG", LogLevelDebug},
{"trace", LogLevelDebug},
// события LogWriter без явного уровня и прочий мусор → info
{"log", LogLevelInfo},
{"", LogLevelInfo},
{" ", LogLevelInfo},
{"unknown", LogLevelInfo},
}
for _, c := range cases {
if got := NormalizeLogLevel(c.in); got != c.want {
t.Errorf("NormalizeLogLevel(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// LogLevels — ровно четыре уровня фильтра в фиксированном порядке.
func TestLogLevelsContract(t *testing.T) {
want := []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
if len(LogLevels) != len(want) {
t.Fatalf("len(LogLevels) = %d, want %d", len(LogLevels), len(want))
}
for i := range want {
if LogLevels[i] != want[i] {
t.Fatalf("LogLevels = %v, want %v", LogLevels, want)
}
}
}
// allLevels — набор «отмечены все уровни» (состояние фильтра по умолчанию).
func allLevels() map[string]bool {
m := make(map[string]bool, len(LogLevels))
for _, lvl := range LogLevels {
m[lvl] = true
}
return m
}
// По умолчанию (все уровни отмечены) рендерится полный лог; уровни
// нормализуются при добавлении.
func TestLogBufferRenderAllByDefault(t *testing.T) {
b := NewLogBuffer(1000)
b.Append("error", "e1")
b.Append("warn", "w1") // синоним → warning
b.Append("log", "i1") // неизвестный уровень → info
b.Append("trace", "d1") // синоним → debug
got := b.Render(allLevels())
want := "e1\nw1\ni1\nd1\n"
if got != want {
t.Fatalf("Render(all) = %q, want %q", got, want)
}
entries := b.Entries()
wantLvls := []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
for i, e := range entries {
if e.Level != wantLvls[i] {
t.Fatalf("entry[%d].Level = %q, want %q", i, e.Level, wantLvls[i])
}
}
}
// Показываются только записи выбранных уровней.
func TestLogBufferRenderSelectedOnly(t *testing.T) {
b := NewLogBuffer(1000)
b.Append(LogLevelError, "err line")
b.Append(LogLevelWarning, "warn line")
b.Append(LogLevelInfo, "info line")
b.Append(LogLevelDebug, "debug line")
onlyErrors := map[string]bool{LogLevelError: true}
if got, want := b.Render(onlyErrors), "err line\n"; got != want {
t.Fatalf("Render(error only) = %q, want %q", got, want)
}
errorsAndDebug := map[string]bool{LogLevelError: true, LogLevelDebug: true}
if got, want := b.Render(errorsAndDebug), "err line\ndebug line\n"; got != want {
t.Fatalf("Render(error+debug) = %q, want %q", got, want)
}
}
// Ни один уровень не выбран — лента пуста (и это не ошибка).
func TestLogBufferRenderEmptySelection(t *testing.T) {
b := NewLogBuffer(1000)
b.Append(LogLevelError, "err line")
b.Append(LogLevelInfo, "info line")
if got := b.Render(map[string]bool{}); got != "" {
t.Fatalf("Render(none) = %q, want empty", got)
}
if got := b.Render(nil); got != "" {
t.Fatalf("Render(nil) = %q, want empty", got)
}
}
// Фильтрация применяется и к уже загруженным записям: смена набора уровней
// меняет результат рендера того же буфера без новых Append.
func TestLogBufferRefilterLoadedEntries(t *testing.T) {
b := NewLogBuffer(1000)
b.Append(LogLevelError, "err line")
b.Append(LogLevelInfo, "info line")
all := allLevels()
if got, want := b.Render(all), "err line\ninfo line\n"; got != want {
t.Fatalf("Render(all) = %q, want %q", got, want)
}
delete(all, LogLevelInfo)
if got, want := b.Render(all), "err line\n"; got != want {
t.Fatalf("Render(error) after refilter = %q, want %q", got, want)
}
all[LogLevelInfo] = true // вернули галочку — запись снова видима
if got, want := b.Render(all), "err line\ninfo line\n"; got != want {
t.Fatalf("Render(all) after re-check = %q, want %q", got, want)
}
}
// Новые записи сразу попадают в ленту своего уровня.
func TestLogBufferNewEntriesVisibleImmediately(t *testing.T) {
b := NewLogBuffer(1000)
sel := map[string]bool{LogLevelError: true}
b.Append(LogLevelError, "first")
if got, want := b.Render(sel), "first\n"; got != want {
t.Fatalf("Render = %q, want %q", got, want)
}
b.Append(LogLevelInfo, "noise") // невидимый уровень — ленту не меняет
if got, want := b.Render(sel), "first\n"; got != want {
t.Fatalf("Render after invisible append = %q, want %q", got, want)
}
b.Append(LogLevelError, "second")
if got, want := b.Render(sel), "first\nsecond\n"; got != want {
t.Fatalf("Render after visible append = %q, want %q", got, want)
}
}
// Буфер ограничен: старые записи вытесняются, суммарный объём ≤ лимита.
func TestLogBufferBounded(t *testing.T) {
b := NewLogBuffer(10)
for i := 0; i < 5; i++ {
b.Append(LogLevelInfo, "0123456789") // каждая запись — ровно лимит
}
entries := b.Entries()
if len(entries) != 1 {
t.Fatalf("len(entries) = %d, want 1 (старые вытеснены)", len(entries))
}
if entries[0].Text != "0123456789" {
t.Fatalf("text = %q", entries[0].Text)
}
// одиночная запись длиннее лимита обрезается до последних maxLen символов
b2 := NewLogBuffer(4)
b2.Append(LogLevelInfo, "abcdefgh")
if got := b2.Entries()[0].Text; got != "efgh" {
t.Fatalf("oversized entry text = %q, want %q", got, "efgh")
}
}
// Буфер держит только живые записи: ленивое вытеснение (start) не даёт
// «мёртвым» записям попасть в Entries/Render.
func TestLogBufferLiveEntriesOnly(t *testing.T) {
b := NewLogBuffer(30)
for i := 0; i < 3; i++ {
b.Append(LogLevelInfo, "0123456789") // 10 байт + \n каждая
}
// сумма 33 > 30 → вытесняется первая запись, остаются две живые
entries := b.Entries()
if len(entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 (мёртвая запись скрыта)", len(entries))
}
if entries[0].Text != "0123456789" || entries[1].Text != "0123456789" {
t.Fatalf("unexpected live entries: %+v", entries)
}
if got, want := b.Render(allLevels()), "0123456789\n0123456789\n"; got != want {
t.Fatalf("Render = %q, want %q", got, want)
}
}
// Кап считается по байтам текста, а не по числу записей: короткие записи
// не вытесняются, пока их суммарный объём не превысит лимит.
func TestLogBufferBytesCap(t *testing.T) {
b := NewLogBuffer(100)
for i := 0; i < 3; i++ {
b.Append(LogLevelInfo, "hello") // 5 байт + \n
}
if got := len(b.Entries()); got != 3 {
t.Fatalf("len(Entries) = %d, want 3 (все помещаются)", got)
}
// одна большая запись вытесняет всё старое (live=1) и обрезается
b2 := NewLogBuffer(8)
b2.Append(LogLevelInfo, "short")
b2.Append(LogLevelInfo, "verylongtext")
entries := b2.Entries()
if len(entries) != 1 {
t.Fatalf("len(Entries) = %d, want 1", len(entries))
}
if entries[0].Text != "longtext" {
t.Fatalf("oversized entry text = %q, want %q", entries[0].Text, "longtext")
}
}
// Первый RenderNew отдаёт весь видимый текст, последующие — только новые
// записи (инкрементальная отрисовка: повторный вызов без Append пуст).
func TestLogBufferRenderNewDelta(t *testing.T) {
b := NewLogBuffer(1000)
sel := allLevels()
b.Append(LogLevelError, "e1")
b.Append(LogLevelInfo, "i1")
text, full := b.RenderNew(sel)
if full {
t.Fatalf("первый RenderNew не должен требовать полного пересбора, got full")
}
if text != "e1\ni1\n" {
t.Fatalf("первый RenderNew = %q, want %q", text, "e1\ni1\n")
}
// Без новых записей — пустой дельта.
text, full = b.RenderNew(sel)
if full || text != "" {
t.Fatalf("повторный RenderNew без новых записей = (%q, %v), want (\"\", false)", text, full)
}
// Новые записи — только их текст, без повторной отдачи старых.
b.Append(LogLevelInfo, "i2")
text, _ = b.RenderNew(sel)
if text != "i2\n" {
t.Fatalf("дельта RenderNew = %q, want %q", text, "i2\n")
}
}
// RenderNew применяет фильтр: невидимые уровни не попадают в дельту.
func TestLogBufferRenderNewFiltered(t *testing.T) {
b := NewLogBuffer(1000)
sel := map[string]bool{LogLevelError: true}
b.Append(LogLevelError, "e1")
b.Append(LogLevelInfo, "i1")
b.Append(LogLevelError, "e2")
text, _ := b.RenderNew(sel)
if text != "e1\ne2\n" {
t.Fatalf("RenderNew(error only) = %q, want %q", text, "e1\ne2\n")
}
}
// RenderNew уважает ResetRendered: после полного рендера новые записи
// добавляются дельтом, а не вытесняют уже выведенный текст.
func TestLogBufferRenderNewAfterReset(t *testing.T) {
b := NewLogBuffer(1000)
sel := allLevels()
b.Append(LogLevelInfo, "old")
b.Append(LogLevelInfo, "base")
text, full := b.RenderNew(sel)
if full || text != "old\nbase\n" {
t.Fatalf("RenderNew = (%q, %v), want (\"old\\nbase\\n\", false)", text, full)
}
b.ResetRendered() // имитация полной пересборки ленты
b.Append(LogLevelInfo, "new")
text, full = b.RenderNew(sel)
if full || text != "new\n" {
t.Fatalf("RenderNew после Reset = (%q, %v), want (\"new\\n\", false)", text, full)
}
}
// Вытеснение уже выведенных записей помечает рендер как требующий полного
// пересбора (stale): дельта не может убрать верхние строки.
func TestLogBufferRenderNewEvictionStale(t *testing.T) {
b := NewLogBuffer(25) // записи по 10 байт + \n: помещаются 2, 3-я вытесняет 1-ю
sel := allLevels()
b.Append(LogLevelInfo, "0123456789")
b.Append(LogLevelInfo, "0123456789")
text, full := b.RenderNew(sel)
if full || text != "0123456789\n0123456789\n" {
t.Fatalf("RenderNew = (%q, %v), want two lines", text, full)
}
// Третья запись вытесняет первую (уже отрисованную) — stale.
b.Append(LogLevelInfo, "0123456789")
text, full = b.RenderNew(sel)
if !full {
t.Fatalf("RenderNew после вытеснения = (full=%v), want full=true", full)
}
if text != "0123456789\n0123456789\n" {
t.Fatalf("полный текст после вытеснения = %q, want последние две записи", text)
}
}

43
internal/ui/session.go Normal file
View File

@@ -0,0 +1,43 @@
package ui
import (
"github.com/kamelion/ratatoskr-go/internal/chat"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// Session — одна вкладка-сессия UI: независимый диалог с LLM.
//
// Каждая вкладка имеет собственный UserID и Address, поэтому core видит её как
// отдельный чат со своим активным task (политика «одна задача на чат» работает
// из коробки). Свободная сессия (BoundTask==0) — in-memory диалог с моделью
// вне машины задач; task-сессия (BoundTask>0) — диалог конкретной задачи, у
// которой UserID == task.ChatID (чат задачи уникален).
type Session struct {
UserID chat.UserID
Address chat.Address
Title string
BoundTask int64 // 0 = свободный чат; >0 — задача, к которой привязана вкладка
}
// NewFreeSession создаёт свободную сессию (без задачи). userID/address должны
// быть уникальными (например "ui://sess-<n>"). Транскрипт живёт только в памяти.
func NewFreeSession(userID chat.UserID, address chat.Address) *Session {
return &Session{UserID: userID, Address: address, Title: "Новый чат", BoundTask: 0}
}
// NewTaskSession создаёт сессию, привязанную к задаче. UserID == task.ChatID,
// чтобы Router/core находили активную задачу этой вкладки. address — уникальный
// адрес доставки вкладки (не должен совпадать с UserID других вкладок).
func NewTaskSession(address chat.Address, t storage.Task) *Session {
title := t.Title
if title == "" {
title = "Задача #" + Itoa(t.ID)
}
return &Session{UserID: chat.UserID(t.ChatID), Address: address, Title: title, BoundTask: t.ID}
}
// SessionID — префикс идентификаторов UI-сессий (и UserID, и Address).
const SessionID = "ui://sess-"
// IsFreeSession — true, если вкладка не привязана к задаче.
func IsFreeSession(s *Session) bool { return s != nil && s.BoundTask == 0 }

View File

@@ -0,0 +1,55 @@
package ui
import (
"testing"
"github.com/kamelion/ratatoskr-go/internal/chat"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
func TestNewFreeSession(t *testing.T) {
s := NewFreeSession(chat.UserID("ui://sess-1"), chat.Address("ui://sess-1"))
if s.UserID != chat.UserID("ui://sess-1") {
t.Errorf("UserID = %q, want ui://sess-1", s.UserID)
}
if s.Address != chat.Address("ui://sess-1") {
t.Errorf("Address = %q, want ui://sess-1", s.Address)
}
if s.BoundTask != 0 {
t.Errorf("BoundTask = %d, want 0", s.BoundTask)
}
if !IsFreeSession(s) {
t.Error("free session must be free")
}
}
func TestNewTaskSessionUsesTaskChatID(t *testing.T) {
task := &storage.Task{ID: 7, ChatID: "ui://sess-3", Title: "Фича"}
s := NewTaskSession(chat.Address("ui://sess-3"), *task)
if s.UserID != chat.UserID("ui://sess-3") {
t.Errorf("UserID = %q, want task.ChatID", s.UserID)
}
if s.BoundTask != 7 {
t.Errorf("BoundTask = %d, want 7", s.BoundTask)
}
if s.Title != "Фича" {
t.Errorf("Title = %q, want Фича", s.Title)
}
if IsFreeSession(s) {
t.Error("task session must not be free")
}
}
func TestNewTaskSessionTitleFallback(t *testing.T) {
task := &storage.Task{ID: 3, ChatID: "ui://sess-3"}
s := NewTaskSession(chat.Address("ui://sess-3"), *task)
if s.Title != "Задача #3" {
t.Errorf("Title = %q, want Задача #3", s.Title)
}
}
func TestIsFreeSessionNil(t *testing.T) {
if IsFreeSession(nil) {
t.Error("nil is not free")
}
}

View File

@@ -0,0 +1,99 @@
package ui
import (
"strings"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/model"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// StatePanel — панель «Состояние» (спец 12.13): агенты и трейсы выбранной
// задачи. Снимок (SetText) + live-строки (Append), буфер ограничен.
type StatePanel interface {
// SetText заменяет содержимое снимком трасс.
SetText(text string)
// Append добавляет live-строку (буфер ограничен).
Append(text string)
}
// NilStatePanel — no-op реализация StatePanel для headless-режима и тестов.
type NilStatePanel struct{}
// NewNilStatePanel создаёт NilStatePanel.
func NewNilStatePanel() *NilStatePanel { return &NilStatePanel{} }
func (NilStatePanel) SetText(string) {}
func (NilStatePanel) Append(string) {}
// StateText — снимок трасс задачи (pure-функция, спец 12.13).
func StateText(traces []storage.Trace) string {
var b strings.Builder
for _, tr := range traces {
b.WriteString(strings.ToUpper(tr.Agent) + ": " + string(tr.Status))
if tr.SessionID != "" {
b.WriteString(" (session " + tr.SessionID + ")")
}
b.WriteString("\n")
}
if b.Len() == 0 {
b.WriteString("Нет активных агентов.")
}
return b.String()
}
// TraceLine — строка добавленного/обновлённого трейса субагента.
func TraceLine(agent string, status model.TraceStatus) string {
return strings.ToUpper(agent) + ": " + string(status)
}
// ActivityLine — строка смены этапа агента.
func ActivityLine(agent, stage string) string {
return strings.ToUpper(agent) + " → " + stage
}
// TaskStatusText — строка изменения статуса задачи (бейдж + ID).
func TaskStatusText(e events.TaskStatusChanged) string {
return StatusBadge(e.To) + " #" + Itoa(e.ID) + ": " + string(e.From) + " → " + string(e.To)
}
// StatusBadge — эмодзи-бейдж статуса задачи.
func StatusBadge(s model.Status) string {
switch s {
case model.StatusSuccess:
return "✅"
case model.StatusFailed, model.StatusAborted:
return "❌"
case model.StatusRunning, model.StatusCollecting:
return "⏳"
case model.StatusReady, model.StatusApproved:
return "🟡"
case model.StatusCancelled:
return "🚫"
default:
return "•"
}
}
// Itoa — int64 в строку без зависимостей.
func Itoa(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var b [24]byte
i := len(b)
for v > 0 {
i--
b[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}

View File

@@ -0,0 +1,50 @@
package ui
import (
"strings"
"testing"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/model"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// StateText — снимок трасс (спец 12.13), pure-функция без Fyne.
func TestStateText(t *testing.T) {
got := StateText(nil)
if !strings.Contains(got, "Нет активных агентов.") {
t.Errorf("пусто: got %q", got)
}
tr := storage.Trace{Agent: "analyst", Status: model.TraceRunning, SessionID: "s1"}
got = StateText([]storage.Trace{tr})
if !strings.Contains(got, "ANALYST: running") {
t.Errorf("агент/статус не попали: %q", got)
}
if !strings.Contains(got, "session s1") {
t.Errorf("session не попала: %q", got)
}
}
// Live-строки — чистые хелперы.
func TestStateLines(t *testing.T) {
if TraceLine("analyst", model.TraceRunning) != "ANALYST: running" {
t.Errorf("TraceLine: %q", TraceLine("analyst", model.TraceRunning))
}
if ActivityLine("dev", "review") != "DEV → review" {
t.Errorf("ActivityLine: %q", ActivityLine("dev", "review"))
}
e := events.TaskStatusChanged{ID: 7, From: model.StatusRunning, To: model.StatusSuccess}
got := TaskStatusText(e)
if !strings.Contains(got, "✅") || !strings.Contains(got, "#7") {
t.Errorf("TaskStatusText: %q", got)
}
}
// NilStatePanel — no-op контракт (спец 12.8): безопасно для headless/тестов.
func TestNilStatePanelNoop(t *testing.T) {
p := NewNilStatePanel()
p.SetText("снимок")
p.Append("строка")
}

73
internal/ui/store.go Normal file
View File

@@ -0,0 +1,73 @@
package ui
import (
"context"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// Store — абстракция чтения-модели для UI (спец 12.5).
//
// Возвращает только копии (value-типы), никогда — живые указатели на
// разделяемые контейнеры core/БД. UI читает снимки и реагирует на события;
// мутирует состояние только Core.
type Store interface {
// ListTasks возвращает все задачи (копии), сортировка по updated_at desc.
ListTasks(ctx context.Context) ([]storage.Task, error)
// GetTask возвращает задачу по ID (копию).
GetTask(ctx context.Context, id int64) (storage.Task, error)
// GetHistory возвращает историю диалога задачи (копии).
GetHistory(ctx context.Context, id int64) ([]storage.HistoryMsg, error)
// GetTraces возвращает трассы задачи (копии).
GetTraces(ctx context.Context, id int64) ([]storage.Trace, error)
}
// DBStore — реализация Store поверх *storage.Storage (БД).
type DBStore struct {
s *storage.Storage
}
// NewDBStore создаёт DBStore на основе БД.
func NewDBStore(s *storage.Storage) *DBStore {
return &DBStore{s: s}
}
// ListTasks реализует Store: deref-копии записей, без указателей.
func (d *DBStore) ListTasks(ctx context.Context) ([]storage.Task, error) {
rows, err := d.s.ListTasks(ctx, storage.TaskFilter{Limit: 200})
if err != nil {
return nil, err
}
out := make([]storage.Task, 0, len(rows))
for _, r := range rows {
out = append(out, *r)
}
return out, nil
}
// GetTask реализует Store.
func (d *DBStore) GetTask(ctx context.Context, id int64) (storage.Task, error) {
t, err := d.s.GetTask(ctx, id)
if err != nil {
return storage.Task{}, err
}
return *t, nil
}
// GetHistory реализует Store.
func (d *DBStore) GetHistory(ctx context.Context, id int64) ([]storage.HistoryMsg, error) {
return d.s.GetHistory(ctx, id)
}
// GetTraces реализует Store.
func (d *DBStore) GetTraces(ctx context.Context, id int64) ([]storage.Trace, error) {
rows, err := d.s.GetTraces(ctx, id)
if err != nil {
return nil, err
}
out := make([]storage.Trace, 0, len(rows))
for _, r := range rows {
out = append(out, *r)
}
return out, nil
}

107
internal/ui/store_test.go Normal file
View File

@@ -0,0 +1,107 @@
package ui
import (
"context"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
func newTestStore(t *testing.T) *storage.Storage {
t.Helper()
s, err := storage.Open(context.Background(), ":memory:")
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestDBStoreListTasksReturnsCopies(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
id1, err := s.CreateTask(ctx, &storage.Task{ChatID: "ui://local", Title: "A"})
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := s.CreateTask(ctx, &storage.Task{ChatID: "tg://1", Title: "B"}); err != nil {
t.Fatalf("create: %v", err)
}
st := NewDBStore(s)
tasks, err := st.ListTasks(ctx)
if err != nil {
t.Fatalf("ListTasks: %v", err)
}
if len(tasks) != 2 {
t.Fatalf("got %d tasks, want 2", len(tasks))
}
// мутация полученной копии не влияет на БД
tasks[0].Title = "mutated"
fetched, err := s.GetTask(ctx, id1)
if err != nil {
t.Fatalf("get: %v", err)
}
if fetched.Title == "mutated" {
t.Fatal("mutating snapshot leaked into DB")
}
}
func TestDBStoreGetTaskAndHistory(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
id, err := s.CreateTask(ctx, &storage.Task{ChatID: "ui://local", Title: "A"})
if err != nil {
t.Fatalf("create: %v", err)
}
if err := s.AppendHistory(ctx, id, "user", "hello"); err != nil {
t.Fatalf("append: %v", err)
}
st := NewDBStore(s)
task, err := st.GetTask(ctx, id)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if task.ID != id {
t.Fatalf("task.ID = %d, want %d", task.ID, id)
}
hist, err := st.GetHistory(ctx, id)
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(hist) != 1 || hist[0].Content != "hello" {
t.Fatalf("bad history: %+v", hist)
}
}
func TestDBStoreGetTraces(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
id, err := s.CreateTask(ctx, &storage.Task{ChatID: "ui://local"})
if err != nil {
t.Fatalf("create: %v", err)
}
tid, err := s.AppendTrace(ctx, &storage.Trace{TaskID: id, Agent: "dev", Prompt: "p"})
if err != nil {
t.Fatalf("append trace: %v", err)
}
_ = s.UpdateTraceStatus(ctx, tid, storage.TraceSuccess)
st := NewDBStore(s)
traces, err := st.GetTraces(ctx, id)
if err != nil {
t.Fatalf("GetTraces: %v", err)
}
if len(traces) != 1 {
t.Fatalf("got %d traces, want 1", len(traces))
}
if traces[0].Status != storage.TraceSuccess {
t.Fatalf("trace status = %s, want success", traces[0].Status)
}
}

View File

@@ -0,0 +1,37 @@
package ui
import (
"strings"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// TaskDetailPanel — панель «Детали задачи» (спец 12.11): заголовок, статус,
// детали (цель + репозитории) выбранной задачи.
//
// Данные — снимки-копии из Store (спец 12.5). Обновление — на потоке Fyne;
// вызывающий уже внутри fyne.Do. Диалог и «Состояние» — отдельные панели.
type TaskDetailPanel interface {
// ShowTask рендерит детали из снимка задачи.
ShowTask(t storage.Task)
// ShowEmpty сбрасывает к placeholder («—» / пусто) при недоступной задаче.
ShowEmpty()
}
// NilTaskDetailPanel — no-op реализация TaskDetailPanel для headless-режима и тестов.
type NilTaskDetailPanel struct{}
// NewNilTaskDetailPanel создаёт NilTaskDetailPanel.
func NewNilTaskDetailPanel() *NilTaskDetailPanel { return &NilTaskDetailPanel{} }
func (NilTaskDetailPanel) ShowTask(storage.Task) {}
func (NilTaskDetailPanel) ShowEmpty() {}
// TaskDetailText — строка деталей задачи (pure-функция, спец 12.11).
func TaskDetailText(t storage.Task) string {
detail := "Цель: " + t.Goal
if len(t.Repos) > 0 {
detail += "\nРепозитории: " + strings.Join(t.Repos, ", ")
}
return detail
}

View File

@@ -0,0 +1,36 @@
package ui
import (
"strings"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// TaskDetailText — строка деталей задачи (спец 12.11), pure-функция без Fyne.
func TestTaskDetailText(t *testing.T) {
noRepos := storage.Task{Goal: "Понять код"}
got := TaskDetailText(noRepos)
if got != "Цель: Понять код" {
t.Errorf("без repos: got %q", got)
}
if strings.Contains(got, "Репозитории") {
t.Errorf("без repos не должно быть репозиториев: %q", got)
}
withRepos := storage.Task{Goal: "Рефакторинг", Repos: []string{"a", "b"}}
got = TaskDetailText(withRepos)
if !strings.Contains(got, "Цель: Рефакторинг") {
t.Errorf("цель не попала: %q", got)
}
if !strings.Contains(got, "Репозитории: a, b") {
t.Errorf("репозитории не попали: %q", got)
}
}
// NilTaskDetailPanel — no-op контракт (спец 12.8): безопасно для headless/тестов.
func TestNilTaskDetailPanelNoop(t *testing.T) {
p := NewNilTaskDetailPanel()
p.ShowTask(storage.Task{ID: 1, Goal: "x"})
p.ShowEmpty()
}

View File

@@ -0,0 +1,40 @@
package ui
import (
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// TaskListPanel — панель «Список задач» (спец 12.10): все задачи строками.
//
// Данные — снимки-копии из Store (спец 12.5), заменяются через SetTasks.
// Состояние выбора живёт в окне: окно зовёт Select, чтобы подсветить строку.
// Контейнер виджета отдаётся окну — реализация (Fyne) сама владеет виджетом.
type TaskListPanel interface {
// SetTasks заменяет снимок списка и перерисовывает виджет.
SetTasks(tasks []storage.Task)
// Select подсвечивает строку задачи id (управляется окном).
Select(id int64)
// OnSelect регистрирует колбэк клика по строке (окно грузит детали).
OnSelect(fn func(id int64))
}
// NilTaskListPanel — no-op реализация TaskListPanel для headless-режима и тестов.
type NilTaskListPanel struct {
onSelect func(id int64)
}
// NewNilTaskListPanel создаёт NilTaskListPanel.
func NewNilTaskListPanel() *NilTaskListPanel { return &NilTaskListPanel{} }
func (p *NilTaskListPanel) SetTasks([]storage.Task) {}
func (p *NilTaskListPanel) Select(int64) {}
func (p *NilTaskListPanel) OnSelect(fn func(int64)) { p.onSelect = fn }
// TaskTitle — строка задачи в списке (pure-функция, спец 12.10).
func TaskTitle(t storage.Task) string {
title := t.Title
if title == "" {
title = "(без названия)"
}
return title + "\n" + string(t.Status)
}

View File

@@ -0,0 +1,40 @@
package ui
import (
"strings"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// TaskTitle — строка в списке задач (спец 12.10), pure-функция без Fyne.
func TestTaskTitle(t *testing.T) {
empty := storage.Task{Status: storage.StatusReady}
got := TaskTitle(empty)
if !strings.Contains(got, "(без названия)") {
t.Errorf("пустое название: got %q, want «(без названия)»", got)
}
if !strings.Contains(got, string(storage.StatusReady)) {
t.Errorf("строка должна содержать статус, got %q", got)
}
named := storage.Task{Title: "Тест", Status: storage.StatusRunning}
got = TaskTitle(named)
if !strings.Contains(got, "Тест") {
t.Errorf("название не попало в строку: %q", got)
}
if !strings.Contains(got, string(storage.StatusRunning)) {
t.Errorf("статус не попал в строку: %q", got)
}
}
// NilTaskListPanel — no-op контракт (спец 12.8): безопасно для headless/тестов.
func TestNilTaskListPanelNoop(t *testing.T) {
p := NewNilTaskListPanel()
p.SetTasks([]storage.Task{{ID: 1}})
p.Select(1)
var got []int64
p.OnSelect(func(id int64) { got = append(got, id) })
// Nil не вызывает колбэки — нет виджета, но вызовы безопасны.
}

40
internal/ui/view.go Normal file
View File

@@ -0,0 +1,40 @@
// Package ui — контроллер и представления десктопного интерфейса (Fyne).
//
// Слой обмена с ядром: однонаправленный поток (спец 12.212.5).
// - Core мутирует состояние; UI только читает снимки и реагирует на события.
// - Действия UI = команды (CreateTask/ApproveTask/...), которые зовут Core.
// - Подписчик шины (Controller) получает события и перекладывает их в View
// (реализация Fyne) через fyne.Do — никаких прямых вызовов Fyne из core.
package ui
import (
"github.com/kamelion/ratatoskr-go/internal/events"
)
// View — интерфейс, который реализует Fyne-слой окна.
//
// Методы вызываются из горутины Controller (горутина Hub-подписчика), поэтому
// реализация обязана перекладывать работу на поток Fyne через fyne.Do /
// fyne.DoAndWait, либо использовать thread-safe структуры (binding).
type View interface {
OnTaskCreated(e events.TaskCreated)
OnTaskUpdated(e events.TaskUpdated)
OnTaskDeleted(e events.TaskDeleted)
OnTaskStatusChanged(e events.TaskStatusChanged)
OnHistoryAppended(e events.HistoryAppended)
OnTraceAppended(e events.TraceAppended)
OnAgentActivity(e events.AgentActivity)
OnLog(e events.LogLine)
}
// NilView — no-op реализация View для headless-режима и тестов.
type NilView struct{}
func (NilView) OnTaskCreated(events.TaskCreated) {}
func (NilView) OnTaskUpdated(events.TaskUpdated) {}
func (NilView) OnTaskDeleted(events.TaskDeleted) {}
func (NilView) OnTaskStatusChanged(events.TaskStatusChanged) {}
func (NilView) OnHistoryAppended(events.HistoryAppended) {}
func (NilView) OnTraceAppended(events.TraceAppended) {}
func (NilView) OnAgentActivity(events.AgentActivity) {}
func (NilView) OnLog(events.LogLine) {}

68
internal/ui/window.go Normal file
View File

@@ -0,0 +1,68 @@
package ui
import (
"context"
"github.com/kamelion/ratatoskr-go/internal/chat"
"github.com/kamelion/ratatoskr-go/internal/events"
)
// UID — фиксированный пользовательский идентификатор UI-канала (один пользователь).
const UID = chat.UserID("ui://local")
// Address — адрес доставки для UI-канала.
const Address = chat.Address("ui://local")
// Window — окно десктопного UI.
//
// Объединяет две роли:
// - chat.Channel — ещё одна реализация канала (спец 12.1): входящие от
// пользователя (ввод) уходят в Router → Core; исходящие (Send/Ask) — из
// Core/Router попадают в окно (диалог).
// - View — событийный мост из Controller (статусы, история, трейсы, логи).
//
// Реализация (Fyne) живёт в internal/ui/desktop под build-tag `cgo`.
type Window interface {
chat.Channel
View
// SetOnQuit задаёт колбэк полного выхода (кнопка «Завершить»).
SetOnQuit(func())
}
// NilWindow — no-op окно для headless-режима (--noui) и тестов.
// Реализует Window: канал без доставки и View без действий.
type NilWindow struct {
handler chat.Handler
}
// NewNilWindow создаёт NilWindow.
func NewNilWindow() *NilWindow { return &NilWindow{} }
func (n *NilWindow) OnMessage(h chat.Handler) { n.handler = h }
func (n *NilWindow) SetOnQuit(func()) {}
func (n *NilWindow) Run(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
func (n *NilWindow) Send(context.Context, chat.Address, chat.Message) error { return nil }
func (n *NilWindow) Ask(context.Context, chat.Address, chat.Message) error { return nil }
func (n *NilWindow) Close() error { return nil }
// Submit отправляет текст пользователя в Router (ввод в окне).
func (n *NilWindow) Submit(text string) {
if n.handler != nil {
n.handler(chat.Incoming{UserID: UID, Address: Address, Channel: n, Msg: chat.Message{Text: text}})
}
}
func (n *NilWindow) OnTaskCreated(events.TaskCreated) {}
func (n *NilWindow) OnTaskUpdated(events.TaskUpdated) {}
func (n *NilWindow) OnTaskDeleted(events.TaskDeleted) {}
func (n *NilWindow) OnTaskStatusChanged(events.TaskStatusChanged) {}
func (n *NilWindow) OnHistoryAppended(events.HistoryAppended) {}
func (n *NilWindow) OnTraceAppended(events.TraceAppended) {}
func (n *NilWindow) OnAgentActivity(events.AgentActivity) {}
func (n *NilWindow) OnLog(events.LogLine) {}

View File

@@ -0,0 +1,80 @@
package ui
import (
"context"
"testing"
"time"
"github.com/kamelion/ratatoskr-go/internal/chat"
)
func TestCommandsMapToText(t *testing.T) {
var got []string
c := NewCommands(func(text string) { got = append(got, text) })
c.Start()
c.Cancel()
c.Skip()
c.Retry(7)
c.Status(7)
c.Continue(3)
c.Approve()
c.SendText("просто текст")
want := []string{"/start", "/cancel", "/skip", "/retry 7", "/status 7", "/continue 3", "создавай", "просто текст"}
if len(got) != len(want) {
t.Fatalf("got %d commands, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("command %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestNilWindowSubmitReachesHandler(t *testing.T) {
w := NewNilWindow()
incoming := make(chan chat.Incoming, 1)
w.OnMessage(func(inc chat.Incoming) {
incoming <- inc
})
w.Submit("/start")
select {
case inc := <-incoming:
if inc.UserID != UID {
t.Errorf("UserID = %q, want %q", inc.UserID, UID)
}
if inc.Address != Address {
t.Errorf("Address = %q, want %q", inc.Address, Address)
}
if inc.Msg.Text != "/start" {
t.Errorf("Msg.Text = %q, want /start", inc.Msg.Text)
}
case <-time.After(2 * time.Second):
t.Fatal("handler not called")
}
}
func TestNilWindowRunStopsOnCancel(t *testing.T) {
w := NewNilWindow()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- w.Run(ctx) }()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not stop on cancel")
}
}
func TestNilWindowSendNoop(t *testing.T) {
w := NewNilWindow()
if err := w.Send(context.Background(), Address, chat.Message{Text: "hi"}); err != nil {
t.Fatalf("Send: %v", err)
}
}

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage" "github.com/kamelion/ratatoskr-go/internal/storage"
) )
@@ -56,6 +57,27 @@ type Worker struct {
// подменяемый poll для тестов // подменяемый poll для тестов
pollFn PollTaskFunc pollFn PollTaskFunc
// Events — издатель доменных событий для UI. nil — события выключены.
Events events.Publisher
}
// publish отправляет доменное событие, если задан издатель.
func (w *Worker) publish(e events.Event) {
if w.Events != nil {
w.Events.Publish(e)
}
}
// setStatus переводит задачу в новый статус: сохраняет в БД и публикует событие.
func (w *Worker) setStatus(ctx context.Context, task *storage.Task, to storage.Status) error {
from := task.Status
task.Status = to
if err := w.Store.UpdateTask(ctx, task); err != nil {
return err
}
w.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: to})
return nil
} }
// runCtx оборачивает контекст запуска субагента, привязывая живое наблюдение // runCtx оборачивает контекст запуска субагента, привязывая живое наблюдение
@@ -192,8 +214,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
} }
// 2. ставим running (после валидации — чтобы плохие имена не жгли состояние) // 2. ставим running (после валидации — чтобы плохие имена не жгли состояние)
task.Status = storage.StatusRunning if err := w.setStatus(ctx, task, storage.StatusRunning); err != nil {
if err := w.Store.UpdateTask(ctx, task); err != nil {
return fmt.Errorf("%w: set running: %v", ErrUpdate, err) return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
} }
w.notifyStatus(ctx, task, storage.StatusRunning) w.notifyStatus(ctx, task, storage.StatusRunning)
@@ -261,16 +282,14 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
case 0: case 0:
// продолжаем на ревью // продолжаем на ревью
case -1: case -1:
task.Status = storage.StatusTimeout if e := w.setStatus(ctx, task, storage.StatusTimeout); e != nil {
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e) return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
} }
w.notifyStatus(ctx, task, storage.StatusTimeout) w.notifyStatus(ctx, task, storage.StatusTimeout)
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output) w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
return nil return nil
default: default:
task.Status = storage.StatusFailed if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e) return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
} }
w.notifyStatus(ctx, task, storage.StatusFailed) w.notifyStatus(ctx, task, storage.StatusFailed)
@@ -309,9 +328,8 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
if verdict == nil { if verdict == nil {
// невалидный JSON даже после retry → failed с объяснением. // невалидный JSON даже после retry → failed с объяснением.
task.Status = storage.StatusFailed
explain := "reviewer вернул невалидный/пустой вердикт (даже после повтора)." explain := "reviewer вернул невалидный/пустой вердикт (даже после повтора)."
if e := w.Store.UpdateTask(ctx, task); e != nil { if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e) return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
} }
w.notifyStatus(ctx, task, storage.StatusFailed) w.notifyStatus(ctx, task, storage.StatusFailed)
@@ -325,13 +343,12 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
w.failTask(ctx, task) w.failTask(ctx, task)
return pErr return pErr
} }
task.Status = storage.StatusSuccess if e := w.setStatus(ctx, task, storage.StatusSuccess); e != nil {
if e := w.Store.UpdateTask(ctx, task); e != nil { return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notifyStatus(ctx, task, storage.StatusSuccess)
return nil
} }
w.notifyStatus(ctx, task, storage.StatusSuccess)
return nil
}
// Не пройдено: если есть итерации — dev дорабатывает. // Не пройдено: если есть итерации — dev дорабатывает.
if iter+1 < maxReviewIterations { if iter+1 < maxReviewIterations {
@@ -341,8 +358,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
} }
// Лимит исчерпан → failed с объяснением. // Лимит исчерпан → failed с объяснением.
task.Status = storage.StatusFailed if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e) return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
} }
w.notify(ctx, task, fmt.Sprintf("Задача #%d: failed — ревью не пройдено за %d итераций", task.ID, maxReviewIterations)) w.notify(ctx, task, fmt.Sprintf("Задача #%d: failed — ревью не пройдено за %d итераций", task.ID, maxReviewIterations))
@@ -375,8 +391,7 @@ func (w *Worker) reviewWithRetry(ctx context.Context, taskID int64, cwd, prompt
// failTask помечает задачу failed и уведомляет владельца. // failTask помечает задачу failed и уведомляет владельца.
func (w *Worker) failTask(ctx context.Context, task *storage.Task) { func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
task.Status = storage.StatusFailed if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
if e := w.Store.UpdateTask(ctx, task); e != nil {
log.Printf("worker: task %d: set failed: %v", task.ID, e) log.Printf("worker: task %d: set failed: %v", task.ID, e)
} }
w.notifyStatus(ctx, task, storage.StatusFailed) w.notifyStatus(ctx, task, storage.StatusFailed)

View File

@@ -0,0 +1,145 @@
<#
build-publish-ui.ps1 — локальная сборка Windows-бинаря с UI (Fyne) и публикация в Gitea Packages.
Почему локально: Fyne-окно требует cgo + C-компилятор (MinGW/gcc), которых нет в CI.
CI собирает ТОЛЬКО headless-linux (см. .gitea/workflows/ci.yaml). Windows-бинарь
с графическим окном собирается на Windows-машине Камиля и публикуется скриптом ниже
в ту же версию commit-<sha7>, что и CI, с теми же companion-файлами (.version/.sha256),
чтобы автообновление (U4 checksum, ResolveLatest) работало без изменений.
Требования:
- Go с доступным C-компилятором (CGO_ENABLED=1). WinLibs gcc: см. память
`mem:binator/cgo-winlibs-gcc`. Каталог с gcc.exe можно задать через
GCC_PATH (ниже); если не задан — gcc должен быть уже в PATH.
- Файл scripts/.gitea-creds (git-ignored, см. ниже) с токенами.
Формат (по одной `ключ=значение` на строку):
GITEA_TOKEN=write:packages-токен
UPDATE_TOKEN=read:package-токен
GIT_MAIN_URL=http://gitea.hal9000.home
PROJECT_PATH=..\..\foo (опционально) корень проекта (где лежат cmd/
и go.mod); относительно папки scripts/ или
абсолютный; без него — папка рядом с
скриптом
GCC_PATH=D:\MinGW\bin (опционально) каталог, где лежит gcc.exe;
дописывается в PATH перед проверкой и
сборкой; абсолютный или относительно
scripts/; без него — gcc из текущего PATH
- Ветка уже запушена в `main` (VERSION = commit-<sha7> от текущего HEAD).
Пример:
powershell -ExecutionPolicy Bypass -File scripts/build-publish-ui.ps1
#>
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$CredsFile = Join-Path $ScriptDir ".gitea-creds"
$Package = "ratatoskr"
$Owner = "kamelion"
$Filename = "ratatoskr-windows-amd64.exe"
# --- 0. Проверка флага CGO_ENABLED (не зависит от creds) ---
if ($env:CGO_ENABLED -eq "0") { Write-Error "CGO_ENABLED=0 — нужен C-компилятор (MinGW). Уберите его из env." }
# --- 1. Чтение секретов ---
if (-not (Test-Path $CredsFile)) {
Write-Error "Нет файла секретов: $CredsFile`nФормат: GITEA_TOKEN=... / UPDATE_TOKEN=... / GIT_MAIN_URL=..."
}
$creds = @{}
Get-Content $CredsFile | ForEach-Object {
$line = $_.Trim()
if ($line -and -not $line.StartsWith("#")) {
$kv = $line.Split("=", 2)
if ($kv.Count -eq 2) { $creds[$kv[0].Trim()] = $kv[1].Trim() }
}
}
$giteaToken = $creds["GITEA_TOKEN"]
$updateToken = $creds["UPDATE_TOKEN"]
$giteaUrl = $creds["GIT_MAIN_URL"]
if (-not $giteaToken) { Write-Error "В $CredsFile нет GITEA_TOKEN (нужен write:packages)." }
if (-not $giteaUrl) { Write-Error "В $CredsFile нет GIT_MAIN_URL." }
# --- 1a. C-тулчейн (gcc): GCC_PATH из creds дописывается в PATH ---
# Путь абсолютный либо относительно папки scripts/; без GCC_PATH — gcc
# берётся из уже установленного PATH.
$gccPath = $creds["GCC_PATH"]
if ($gccPath -ne "" -and $gccPath) {
$gccAbs = Join-Path $ScriptDir $gccPath
$gccBin = (Resolve-Path -LiteralPath $gccAbs -ErrorAction SilentlyContinue).Path
if (-not $gccBin) {
$gccAbs = $gccPath # не нашлось относительно scripts/ — пробуем как абсолютный
$gccBin = (Resolve-Path -LiteralPath $gccAbs -ErrorAction SilentlyContinue).Path
}
if (-not $gccBin) { Write-Error "GCC_PATH не найден: $gccPath (или относительно $ScriptDir)" }
$env:Path = "$gccBin;$env:Path"
Write-Host "[gcc] PATH += $gccBin"
}
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
Write-Host "gcc не найден в PATH. Пример (WinLibs):"
Write-Host ' D:\MinGW\bin, затем:
GCC_PATH=D:\MinGW\bin — в scripts/.gitea-creds'
Write-Error "C-компилятор gcc не найден."
}
Write-Host "[ok] gcc: $((gcc --version | Select-Object -First 1))"
# --- 1b. Корень целевого проекта ---
# PROJECT_PATH из .gitea-creds (относительно папки scripts/) либо, если не задан,
# папка рядом со скриптом (прежнее поведение).
$projPath = $creds["PROJECT_PATH"]
if ($projPath -ne "" -and $projPath) {
$RepoRoot = (Resolve-Path -LiteralPath (Join-Path $ScriptDir $projPath)).Path
if (-not $RepoRoot) { Write-Error "PROJECT_PATH не найден: $projPath (относительно $ScriptDir)" }
} else {
$RepoRoot = Split-Path -Parent $ScriptDir
}
Set-Location $RepoRoot
Write-Host "[root] $RepoRoot"
# --- 2. Версия = commit-<sha7> текущего HEAD (совпадает с именованием CI) ---
$sha = (git rev-parse --short HEAD).Trim()
if (-not $sha) { Write-Error "git rev-parse --short HEAD не дал хэш." }
$Version = "commit-$sha"
Write-Host "[build] VERSION=$Version FILENAME=$Filename"
# --- 3. Сборка (cgo + UI) ---
$env:CGO_ENABLED = "1"
$out = Join-Path $RepoRoot $Filename
$ldflags = "-s -w -X main.version=${Version} -X main.updateToken=${updateToken}"
Write-Host "[build] go build -ldflags=... -o $Filename"
$goArgs = @("build", "-ldflags=$ldflags", "-o", $out, "./cmd/ratatoskr/")
go $goArgs
if ($LASTEXITCODE -ne 0) { Write-Error "go build завершился с кодом $LASTEXITCODE" }
# --- 4. Companion-файлы (.version, .sha256) ---
$sha256Hex = (Get-FileHash -Algorithm SHA256 $out).Hash.ToLower()
$verFile = "$out.version"
$sumFile = "$out.sha256"
Set-Content -Path $verFile -Value $Version -NoNewline -Encoding ascii
Set-Content -Path $sumFile -Value $sha256Hex -NoNewline -Encoding ascii
Write-Host "[ok] bin: $out ($([math]::Round((Get-Item $out).Length/1MB,1)) MB)"
Write-Host "[ok] sha256: $sha256Hex"
# --- 5. Публикация в Gitea Packages ---
$api = "$giteaUrl/api/packages/$Owner/generic/$Package/$Version"
$files = @($Filename, "$Filename.version", "$Filename.sha256")
foreach ($f in $files) {
Write-Host "[put] $api/$f"
curl.exe -sS -X PUT `
-H "Authorization: token $giteaToken" `
-H "Content-Type: application/octet-stream" `
"$api/$f" `
--data-binary "@$f"
if ($LASTEXITCODE -ne 0) { Write-Error "curl PUT $f завершился с кодом $LASTEXITCODE" }
}
# --- 6. Самопроверка: читаем .sha256 обратно и сверяем (контракт U4) ---
Write-Host "[verify] перечитываю $Filename.sha256 из Gitea..."
$remoteSum = (curl.exe -sS -H "Authorization: token $giteaToken" "$api/$Filename.sha256").Trim()
if ($remoteSum -ne $sha256Hex) {
Write-Error "Проверка не сошлась: remote=$remoteSum local=$sha256Hex"
}
$remoteVer = (curl.exe -sS -H "Authorization: token $giteaToken" "$api/$Filename.version").Trim()
if ($remoteVer -ne $Version) {
Write-Error "Версия не сошлась: remote=$remoteVer local=$Version"
}
Write-Host "[ok] опубликовано и проверено: $Version/$Filename (+.version, +.sha256)"