30 Commits

Author SHA1 Message Date
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
ki.sagidullin
be4d749c45 fix(opencode): считать реальный прогресс стрима для idle-детекции
All checks were successful
CI / test (pull_request) Successful in 1m9s
CI / build-and-package (amd64, linux) (pull_request) Successful in 53s
CI / build-and-package (amd64, windows) (pull_request) Successful in 52s
Растущий в один text-парт стрим (text-delta) и reasoning больше не
выглядят как зависшая нейронка: idle-таймер сбрасывается по росту
числа партов и суммарной длины text/reasoning.
2026-08-19 19:11:26 +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
ki.sagidullin
b5fb583c90 test: починить тесты на Windows
All checks were successful
CI / test (pull_request) Successful in 41s
CI / build-and-package (amd64, linux) (pull_request) Successful in 37s
CI / build-and-package (amd64, windows) (pull_request) Successful in 34s
- E2E (app): e2eFakeAPI переведён на v2 HTTP API opencode (/api/*) с
  определением агента по тексту промпта; Router получает processed-счётчик
  и WaitProcessed, e2eChannel.deliver ждёт асинхронную обработку — убирает
  гонку «запрос сразу после deliver» и коллатеральный 'database is closed'.
- app_test: одинарные YAML-кавычки для путей Windows (backslash-escape) +
  закрытие Store в TestNew/TestNew_RunCtxCancel/TestNew_UpdateWiring.
- config_test: абсолютный путь строится с корнем тома (C:\...) и одинарными
  кавычками YAML.
- opencode/server_test: fakeServeBin на Windows — .cmd с ping (#!/bin/sh
  не исполняется).
- worker_test: TestWorkerSemaphore поллит до целевого статуса вместо
  фиксированных sleep (git на Windows медленнее).
2026-08-19 10:47:55 +05:00
3c8528dbd9 Merge pull request 'feat(opencode): переход на v2 HTTP API opencode (хардпин модели, поллинг вердикта)' (#5) from feat/ad025c1 into main
Some checks failed
CI / test (push) Failing after 30s
CI / build-and-package (amd64, linux) (push) Successful in 49s
CI / build-and-package (amd64, windows) (push) Successful in 52s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/5
2026-08-19 08:55:16 +05:00
ki.sagidullin
9f64be4ea5 feat(opencode): переход на v2 HTTP API opencode (хардпин модели, поллинг вердикта)
Some checks failed
CI / test (pull_request) Failing after 32s
CI / build-and-package (amd64, linux) (pull_request) Successful in 42s
CI / build-and-package (amd64, windows) (pull_request) Successful in 42s
- client.go: эндпоинты /api/* (create+model, prompt-admit, message, active, interrupt)
- runner.go: неблокирующий prompt + поллинг новых assistant-сообщений;
  завершение = сессия ушла из активных дренажей + стабильное финальное сообщение
- config.go: чтение top-level model из opencode.jsonc (JSONC-стрип) + хардпин в сессию
- server.go: healthcheck /api/health, MinVersion=1.18.18, понятная ошибка для старого бинаря
- класс O5 WARN: устойчивость к v1-конфигу провайдера (npm/options игнорируются v2)
- README: раздел интеграции, минимальная версия opencode, предупреждения
- .serena: актуализация памяти (core, tech_stack)
2026-08-19 08:29:11 +05:00
ad025c1668 Merge pull request 'refactor: чистка мёртвого кода, лимит ходов D3, HTML-экранирование и UTF-8 обрезка в Telegram' (#4) from feat/83bf5b6fbb8b238c into main
Some checks failed
CI / test (push) Failing after 31s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Successful in 35s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/4
2026-08-18 23:14:50 +05:00
ki.sagidullin
1459670ce9 refactor: чистка мёртвого кода, лимит ходов D3, HTML-экранирование и UTF-8 обрезка в Telegram
Some checks failed
CI / test (pull_request) Failing after 34s
CI / build-and-package (amd64, linux) (pull_request) Successful in 34s
CI / build-and-package (amd64, windows) (pull_request) Successful in 36s
2026-08-18 23:13:04 +05:00
Hermes
7eb3a0292c fix(opencode): убрать смешение слоёв API — перейти целиком на experimental (/session)
Some checks failed
CI / test (push) Failing after 32s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 38s
Корень проблемы «не получаем результаты»: клиент смешивал два слоя opencode
serve. CreateSession ходил на /api/session (v2, ждал {data.id}), Verdict — на
/api/session/{id}/message?order=desc и ждал {data:[{type,content}]}, где поле
content[].type/text физически отсутствует, поэтому вердикт никогда не находился
и поллинг уходил в вечный таймаут. Abort и вовсе звал несуществующий /interrupt.

Теперь весь код на experimental-слое, как сверено с sst/opencode (ветка dev):
- CreateSession: POST /session → голая Session, id в .id.
- Send: блокирующий POST /session/{id}/message, тело {parts:[{type:text,text}]},
  вердикт из частей parts[].type=="text" ответа. Это и есть результат — метод
  Verdict и отдельный GET удалены.
- textCount (прогресс): GET /session/{id}/message → голый массив [{info, parts}].
- Abort: POST /session/{id}/abort.

Runner: блокирующий Send запускается в горутине (канал вердикта/ошибки),
параллельно поллим textCount (рост text-частей сбрасывает idle-таймер). При
idle/hard-таймауте или отмене контекста — Abort + cancel() Send-горутины → rc=-1.

Send ходит через отдельный http.Client без жёсткого Timeout (управляется ctx),
чтобы длинная генерация не обрывалась на 30s. Тесты/fakeAPIServer переведены на
экспериментальный формат. Версия → 0.2.2.
2026-08-18 20:49:34 +05:00
Hermes
a64e3d6cc3 feat(chat): асинхронная обработка входящих — отзывчивый интерфейс
Some checks failed
CI / test (push) Failing after 31s
CI / build-and-package (amd64, linux) (push) Successful in 43s
CI / build-and-package (amd64, windows) (push) Successful in 43s
Router теперь обрабатывает входящие в воркер-горутине (FIFO-очередь с
буфером 256) вместо синхронного вызова onUserMsg из long-poll цикла
канала. Долгий вызов аналитика (Decide) больше не блокирует приём
новых сообщений от Telegram: цикл getUpdates продолжает работать.

- router.go: NewRouter запускает processLoop; handleIncoming кладёт
  событие в канал и возвращается; маршрутизация + pending по-прежнему
  обновляются синхронно под мьютексом.
- router_test.go: fakeOnMsg стал потокобезопасным с ожиданием числа
  входящих (wait), т.к. обработка теперь асинхронная.

Преимущества: интерфейс не замирает на время анализа; порядок входящих
сохраняется (FIFO). Ограничение: воркер один — при очень долгом аналитике
следующие сообщения ждут в очереди, но канал их продолжает принимать.
2026-08-18 19:55:46 +05:00
Hermes
774ebf135a fix(opencode): отправка сообщения — путь без /api (POST /session/:id/message)
All checks were successful
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Successful in 39s
Убран префикс /api из POST-запроса на отправку сообщения, чтобы
совпадать с фактическим роутом serve v1.18. GET чтения сообщений
остаётся на /api/session/:id/message.

- client.go: Send → POST /session/:id/message
- тесты: fakeAPIServer отвечает на POST /session/{id}/message (GET — /api)
2026-08-18 19:28:45 +05:00
Hermes
e1167c9537 feat(opencode): v1.18 — перейти с POST /session/:id/prompt на /session/:id/message
All checks were successful
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 36s
CI / build-and-package (amd64, windows) (push) Successful in 36s
Промпт отправляется в /api/session/:id/message (v1.18) с новым форматом
тела parts:[{type:"text"}], вместо {prompt:{text}}. Причина перехода:
/prompt сам подставляет не ту модель, что в конфиге; /message использует
модель из конфига.

- client.go: Send → POST /message + тело parts; шапка-док v1.18
- тесты: fakeAPIServer + e2eFakeAPI отвечают на POST /message (GET уже был)
2026-08-18 18:58:23 +05:00
Hermes
ad9c2dd522 feat(config): уровень логирования log.level (info|debug), по умолчанию info
All checks were successful
CI / test (push) Successful in 41s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 35s
Отладочные логи API-вызовов к opencode serve теперь включаются только при
log.level=debug (гейт через Client.Debug <- Runner.Debug <- cfg.Log.Debug()).
По умолчанию 'info' — отладочных логов нет.

- config: добавлен LogCfg{Level}, дефолт 'info', валидация (info|debug)
- opencode: Client.Debug и Runner.Debug — гейттят логи do()
- app: Runner.Debug из cfg.Log.Debug()
- config.yaml.example: задокументирован log.level
- тесты: дефолт=info, level=debug, невалидный уровень
2026-08-18 18:44:37 +05:00
Hermes
f77a6000c7 build: bump version to 0.2.1
All checks were successful
CI / test (push) Successful in 41s
CI / build-and-package (amd64, linux) (push) Successful in 37s
CI / build-and-package (amd64, windows) (push) Successful in 36s
2026-08-18 18:32:04 +05:00
Hermes
ae00556923 feat(debug): логировать все API-вызовы к opencode serve с содержимым
All checks were successful
CI / test (push) Successful in 46s
CI / build-and-package (amd64, linux) (push) Successful in 44s
CI / build-and-package (amd64, windows) (push) Successful in 1m4s
В единой точке Client.do добавлены отладочные логи (всегда включены):
- строка вызова: opencode api <op> -> <METHOD> <url><path>
- тело запроса (усечено до 5000 байт)
- тело ответа 2xx (усечено до 5000 байт)
- тело ответа при ошибке/не-2xx (усечено до 1000 байт)

Нужно для диагностики проблемы аналитика (serve отвечал на модель
opencode/nemotron, ratatoskr не видел вердикт).
2026-08-18 18:23:56 +05:00
Hermes
baf7179085 fix: windows-сборка — заглушка setpgid/killGroup для отсутствия POSIX process-group
All checks were successful
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 36s
CI / build-and-package (amd64, windows) (push) Successful in 36s
procgroup_unix.go имеет unix build-tag; под windows setpgid/killGroup не
определялись → GOOS=windows сборка падала. Добавлен procgroup_windows.go:
setpgid — no-op (нет Setpgid), killGroup — Process.Kill (дочерние группы не
завершаются).
2026-08-18 13:45:05 +05:00
Hermes
733e63339a feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Some checks failed
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Failing after 26s
Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
  выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
  text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
  агент определяется по title сессии
2026-08-18 13:36:54 +05:00
Hermes
b60978121d chore: bump version to 0.2.0
All checks were successful
CI / test (push) Successful in 39s
CI / build-and-package (amd64, linux) (push) Successful in 30s
CI / build-and-package (amd64, windows) (push) Successful in 30s
2026-08-18 08:59:42 +05:00
Hermes
2f26b6ae88 feat: opencode serve — постоянный сервер вместо разовых subprocess (вариант A)
Some checks failed
CI / test (push) Successful in 54s
CI / build-and-package (amd64, linux) (push) Failing after 1h58m55s
CI / build-and-package (amd64, windows) (push) Successful in 59s
Супервайзер Server spawn'ит opencode serve (или ходит на внешний URL),
Runner ходит к нему через 'opencode run --attach <url>'. По умолчанию
serve.enabled=false — историческая spawn-модель сохранена; наличие
serve.url переключает на внешний сервер.

- config: ServeCfg (enabled/hostname/port/url/password) + дефолты; лоадер
  научился int/bool (раньше только string/Duration/struct).
- opencode: Server (Start/Run/Close, /global/health, рестарт упавшего,
  reaper-горутина владеет Wait; .Exited() непригоден для SIGKILL).
- Runner.AttachURL: run --attach <url> при заданном URL, иначе как раньше.
- app: composition root — при включённом serve запускает супервайзер.
- тесты: TestRun_AttachMode, TestServer_{ExternalURL,OwnProcess,Restart}.
2026-08-18 08:35:16 +05:00
79 changed files with 5689 additions and 723 deletions

View File

@@ -16,7 +16,7 @@ internal/
analyst/ аналитик: промпт + разбор JSON-вердикта (opencode agent). Коды A1-A4
worker/ polling-планировщик + dev/reviewer-конвейер + gitops. Коды W*, E*, R*
agents/ встроенные opencode-агенты (analyst.md, dev.md, reviewer.md) через go:embed
opencode/ обёртка запуска opencode, LiveRegistry, парсинг вердикта. Коды O1-O4
opencode/ HTTP-клиент v2 API opencode serve, поллинг вердикта, LiveRegistry. Коды O1-O5
storage/ SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history. Коды S1-S5
update/ автообновление из Gitea Packages. Коды U1-U6
```
@@ -42,6 +42,25 @@ internal/
- **Автообновление:** авто = только Check+уведомление; замена — по /update. Версии в
Gitea Packages `commit-<sha7>/` (не `latest/`). Вердикты агентов — строгий JSON.
## opencode (v2 HTTP API, >= 1.18.18)
- Интеграция с субагентами — через headless `opencode serve`, **v2 API** (префикс `/api/*`).
Минимальная версия opencode **>= 1.18.18** (старый бинарь — только `/global/health`, не годится;
healthcheck падает с понятной ошибкой, класс O1).
- **Хардпин модели:** при создании сессии читается top-level `"model"` из конфига opencode
(`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)` — сигнатура.

View File

@@ -14,6 +14,8 @@
- **opencode** (opencode.ai) — внешний процесс для субагентов (analyst/dev/reviewer).
Управляется через internal/opencode (Runner, LiveRegistry). Настраивается в конфиге
(opencode.bin / config / config_dir / hard_timeout / idle_timeout / poll_ms).
Требуемая версия opencode: **>= 1.18.18** (v2 HTTP API `/api/*`; хардпин model из конфига —
`internal/opencode/config.go`, классы O1/O5). Подробно — `mem:core`.
## Сборка / Makefile
- `make build` — go build -ldflags="-s -w -X main.version=commit-<sha7> -X main.updateToken=..." -o ratatoskr ./cmd/ratatoskr/.

View File

@@ -1,39 +1,6 @@
# the name by which the project can be referenced within Serena
# the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "ratatoskr-go"
# list of languages for which language servers are started; choose from:
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# svelte swift systemverilog terraform toml
# typescript typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- go
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
@@ -55,23 +22,19 @@ ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
@@ -131,3 +94,76 @@ read_only_memory_patterns: []
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# list of additional workspace folder paths for cross-package reference support.
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of language servers to start when using the LSP backend; choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart deno
# elixir elm erlang fortran fsharp
# gdscript gleam go groovy haskell
# haxe hlsl html java json
# julia kotlin latex lean4 lua
# luau markdown matlab msl nextflow
# nix ocaml pascal perl php
# php_phpactor php_phpantom powershell python python_basedpyright
# python_jedi python_pyrefly python_ty qml r
# rego ruby ruby_solargraph rust scala
# scss solidity svelte swift systemverilog
# terraform toml typescript typescript_vts vue
# wolfram yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of the LanguageServerId enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple language servers, the first language server that supports a given file will be used for that file.
# The first language server is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
language_servers:
- go
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- .
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0

View File

@@ -17,7 +17,7 @@ internal/
analyst/ # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1A4)
worker/ # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*)
agents/ # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed
opencode/ # обёртка запуска opencode-процесса, парсинг вердикта (классы O1O4)
opencode/ # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1O5)
storage/ # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1S5)
update/ # автообновление из Gitea Packages (классы U1U6)
```
@@ -129,6 +129,33 @@ update:
| `/status` | версия бинаря + есть ли доступное обновление |
| `/help` | справка по всем командам |
## Интеграция с opencode (субагенты)
Субагенты (analyst / dev / reviewer) запускаются через **headless** `opencode serve`
по **v2 HTTP API** (префикс `/api/*`). Требуемая версия opencode: **>= 1.18.18**
(сборки с v2 HTTP API). Старый бинарь, отвечающий только на `/global/health`,
не подходит: healthcheck падает с понятной ошибкой (класс O1).
Что делает обёртка (`internal/opencode`):
- **Хардпин модели.** При создании сессии в конфиге opencode ищется top-level
`"model"` (`internal/opencode/config.go`) и передаётся в `POST /api/session`
как `{"model":{providerID,id}}`. Это убирает зависимость от fallback-логики
opencode (которая молча выбирает «дефолтную» запись, если модель не задана).
- **Весь код резолва модели устойчив к этому классу проблем (класс O5 WARN):**
- если конфиг не читается / в нём нет `model` — в логи пишется warning;
- фактическая модель ответа (из финального assistant-сообщения) сравнивается
с ожидаемой; расхождение логируется как warning;
- конфиг, написанный по **старой v1-схеме** (`provider.X.npm` / `options`),
молча игнорируется v2 — обёртка этого не «чинит» сама, но предупреждает.
Правильный v2-вид провайдера — `api: { type:"aisdk", package, url }` и
`request.headers` вместо `options.headers`.
- **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt`
durable admit), вердикт собирается из новых assistant-сообщений
(`GET .../message`); завершение ответа — сессия ушла из активных дренажей
(`GET .../active`) и появилось финальное assistant-сообщение, стабильное
несколько опросов подряд.
## Фазы аналитика
Аналитик (`internal/analyst`) возвращает JSON-вердикт с полем `phase`:
@@ -198,7 +225,7 @@ curl -s -H "Authorization: token $TOKEN" "$B/api/packages/kamelion/generic/ratat
| C | C1C4 | `internal/config` |
| A | A1A4 | `internal/analyst` |
| M | M1M5 | `internal/chat` |
| O | O1O4 | `internal/opencode` |
| O | O1O5 | `internal/opencode` |
| S | S1S5 | `internal/storage` |
| W | W1W5 | `internal/worker` |
| E | E1E4 | `internal/worker` (репозитории) |

View File

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

View File

@@ -33,4 +33,8 @@ telegram:
# paths:
# worktree: "./worktrees"
# db: "./ratatoskr.db" # или через env RATATOSKR_DB
# db: "./ratatoskr.db" # или через env RATATOSKR_DB
# log (уровень логирования)
# level: "info" # "info" (по умолчанию) или "debug" — debug включает
# # отладочные логи (напр. все API-вызовы к opencode serve)

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

@@ -0,0 +1,244 @@
# 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(text)` — добавить строку лога; при превышении лимита старые строки
отбрасываются (буфер ограничен, не расти бесконечно).
- Контейнер виджета — отдаётся окну для встраивания в таб «Логи» (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
require (
fyne.io/fyne/v2 v2.6.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.56.0
)
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/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/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/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/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/text v0.22.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // 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/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/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
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/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/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/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
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/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/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/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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
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/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 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=

View File

@@ -8,6 +8,7 @@ import (
"strings"
"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/storage"
)
@@ -35,6 +36,16 @@ type Analyst struct {
Runner OpenCodeRunner
Worktree string // каталог, откуда запускать opencode run
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-ответа аналитика.
@@ -58,6 +69,8 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor
agent = "analyst"
}
a.publish(events.AgentActivity{TaskID: draft.ID, Agent: agent, Stage: "decide"})
if len(history) == 0 && !force {
return core.Decision{}, fmt.Errorf("%w: пустая история диалога", ErrNotReady)
}

View File

@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/signal"
@@ -19,8 +20,10 @@ import (
"github.com/kamelion/ratatoskr-go/internal/chat/telegram"
"github.com/kamelion/ratatoskr-go/internal/config"
"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/storage"
"github.com/kamelion/ratatoskr-go/internal/ui"
"github.com/kamelion/ratatoskr-go/internal/update"
"github.com/kamelion/ratatoskr-go/internal/worker"
)
@@ -49,7 +52,7 @@ const packageOwner = "kamelion"
// не следует путать с build-идентификатором `main.version` (commit-<sha7>),
// который вшивается ldflag'ом и используется автообновлением. Здесь номер
// поднимается вручную перед каждым релизом/публикацией новой сборки.
const Version = "0.1.0"
const Version = "0.2.2"
// App — собранный конвейер.
type App struct {
@@ -60,6 +63,16 @@ type App struct {
Worker *worker.Worker
Updater *update.Updater
tg *telegram.Channel // сохранена для Run
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
}
// New читает конфиг и собирает все зависимости.
@@ -67,7 +80,7 @@ type App struct {
// version — вшитая версия бинаря (ldflag -X main.version).
// updateToken — вшитый токен read:package для авто-обновления
// (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)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrConfig, err)
@@ -102,15 +115,24 @@ func New(configPath, version, updateToken string) (*App, error) {
}
log.Printf("app: db opened %s", cfg.Paths.DB)
// OpenCode: пул serve-процессов (по одному на каталог) + API-runner.
// Служебный root-сервер (worktree) живёт всё время app; остальные лениво.
ocPool := opencode.NewPool(cfg.Paths.Worktree)
ocPool.Bin = cfg.OpenCode.Bin
ocPool.Config = cfg.OpenCode.Config
ocPool.ConfigDir = cfg.OpenCode.ConfigDir
ocPool.DBPath = cfg.OpenCode.DBPath
ocPool.Host = cfg.OpenCode.Serve.Hostname
ocPool.BasePort = cfg.OpenCode.Serve.Port
ocPool.Password = cfg.OpenCode.Serve.Password
// OpenCode runner — один на аналитика и воркер
ocRunner := &opencode.Runner{
Bin: cfg.OpenCode.Bin,
DBPath: cfg.OpenCode.DBPath,
Config: cfg.OpenCode.Config,
ConfigDir: cfg.OpenCode.ConfigDir,
Pool: ocPool,
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
PollInterval: cfg.OpenCode.PollMs.Duration(),
Debug: cfg.Log.Debug(),
Stdout: os.Stderr,
}
@@ -132,9 +154,39 @@ func New(configPath, version, updateToken string) (*App, error) {
Config: cfg,
Store: store,
CoreCtx: coreCtx,
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)
// 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-канал
tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration())
a.tg = tg
@@ -155,6 +207,7 @@ func New(configPath, version, updateToken string) (*App, error) {
GitToken: cfg.Git.Token,
Live: live,
Notify: a, // авто-уведомления владельцу задачи через Router
Events: a.Events,
}
a.Router = router
a.Worker = w
@@ -185,6 +238,19 @@ func (a *App) Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Уже отменённый контекст — не поднимаем подсистемы, graceful shutdown сразу.
if ctx.Err() != nil {
log.Print("app: context already cancelled, skipped start")
return nil
}
// opencode serve: поднимаем служебный корневой сервер (worktree) до старта
// воркера, остальные каталоги — лениво. При неудаче — не стартуем.
if err := a.pool.EnsureRoot(ctx); err != nil {
return fmt.Errorf("opencode: %w", err)
}
defer a.pool.Close()
// Канал для проверки Telegram-ошибки (горутина оборачивает Run)
tgErr := make(chan error, 1)
@@ -202,6 +268,14 @@ func (a *App) Run(ctx context.Context) error {
// Авто-проверка обновления (только уведомление владельца; замена — по /update)
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
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

View File

@@ -21,10 +21,11 @@ func TestNew(t *testing.T) {
t.Setenv("RATATOSKR_DB", dbPath)
// Загружаем без config-файла (дефолты + env)
a, err := New("", "dev", "")
a, err := New("", "dev", "", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
defer a.Store.Close()
if a.Store == nil {
t.Fatal("Store не создан")
}
@@ -51,7 +52,7 @@ func TestNew_MissingToken(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("", "dev", "")
_, err := New("", "dev", "", true)
if err == nil {
t.Fatal("expected error for missing token")
}
@@ -66,7 +67,7 @@ func TestNew_BadDB(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("", "dev", "")
_, err := New("", "dev", "", true)
if err == nil {
t.Fatal("expected error for invalid db path")
}
@@ -83,10 +84,11 @@ func TestNew_RunCtxCancel(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
a, err := New("", "dev", "")
a, err := New("", "dev", "", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
defer a.Store.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // сразу отменяем
@@ -120,15 +122,15 @@ func TestNew_WorktreeCreated(t *testing.T) {
" token: \"test:token\"",
" chat_id: \"12345\"",
"paths:",
" db: \"" + filepath.Join(tmp, "test.db") + "\"",
" worktree: \"" + wt + "\"",
" db: '" + filepath.Join(tmp, "test.db") + "'",
" worktree: '" + wt + "'",
"",
}, "\n")
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
a, err := New(configPath, "dev", "")
a, err := New(configPath, "dev", "", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
@@ -165,7 +167,7 @@ func TestNew_UpdateWiring(t *testing.T) {
" base_url: \"https://hub.example.com\"",
" token: \"cfg-update-token\"",
"paths:",
" db: \"" + dbPath + "\"",
" db: '" + dbPath + "'",
"", // пустая строка в конце
}, "\n")
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
@@ -173,10 +175,11 @@ func TestNew_UpdateWiring(t *testing.T) {
}
// 1) base_url из update-блока, а НЕ git
a, err := New(configPath, "dev", "")
a, err := New(configPath, "dev", "", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
defer a.Store.Close()
if a.Updater == nil {
t.Fatal("Updater не создан")
}
@@ -193,10 +196,11 @@ func TestNew_UpdateWiring(t *testing.T) {
}
// 2) вшитый updateToken перекрывает конфиг
a2, err := New(configPath, "dev", "embedded-update-token")
a2, err := New(configPath, "dev", "embedded-update-token", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
defer a2.Store.Close()
if a2.Updater.Token != "embedded-update-token" {
t.Errorf("Token = %q, want embedded-update-token (вшитый приоритетнее)", a2.Updater.Token)
}
@@ -244,4 +248,4 @@ func TestVersion_Semver(t *testing.T) {
if !re.MatchString(Version) {
t.Errorf("Version = %q, ожидался формат major.minor.patch", Version)
}
}
}

View File

@@ -8,18 +8,22 @@ package app
// Worker.runTask: dev → reviewer → настоящий git push → success
//
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
// а фейк-скрипт opencode различает агентов по argv (аналитик/dev/reviewer) —
// возвращая NDJSON-вердикты нужного формата для каждого.
// а opencode serve эмулируется фейковым HTTP API-сервером (e2eFakeAPI). Агент
// определяется по title сессии (ratatoskr-analyst / ratatoskr-dev / ratatoskr-reviewer),
// вердикты возвращаются как text-части assistant-сообщений.
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
@@ -31,63 +35,114 @@ import (
"github.com/kamelion/ratatoskr-go/internal/worker"
)
// ndjsonText собирает строку NDJSON-события opencode с text-партом:
// {"type":"text","part":{"text":"<payload>"}}. payload — строковое
// представление JSON-вердикта агента (как это делает реальный opencode).
func ndjsonText(t *testing.T, payload string) string {
t.Helper()
b, err := json.Marshal(payload) // экранирует payload как JSON-строку
if err != nil {
t.Fatalf("json.Marshal payload: %v", err)
}
return `{"type":"text","part":{"text":` + string(b) + `}}`
// вердикты фейкового агента по имени.
var e2eAgentVerdicts = map[string]string{
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
"dev": `done`,
"reviewer": `{"passed":true,"comments":[]}`,
}
// e2eFakeOpenCode пишет shell-скрипт, имитирующий opencode run.
// Различает агента по argv ($3 = имя агента после "--agent").
//
// analyst → NDJSON c вердиктом propose (черновик с репозиторием calc)
// dev → простой NDJSON "done"
// reviewer→ NDJSON c {"passed":true} в text-парте
func e2eFakeOpenCode(t *testing.T, dir string) string {
// e2eFakeAPI поднимает фейковый opencode serve, эмулирующий v2 HTTP API
// (пути с префиксом /api/*, см. Client в internal/opencode). Агент
// (analyst/dev/reviewer) определяется по тексту промпта на POST
// /api/session/{id}/prompt; вердикт возвращается как text-часть завершённого
// assistant-сообщения, которое отдаёт GET /api/session/{id}/message.
func e2eFakeAPI(t *testing.T) string {
t.Helper()
var mu sync.Mutex
sessions := map[string]string{} // id → agent
analystNDJSON := ndjsonText(t, `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`)
reviewerNDJSON := ndjsonText(t, `{"passed":true,"comments":[]}`)
// каждый вариант печатаем через printf '%s' с одинарными кавычками:
// NDJSON содержит двойные кавычки и бэкслеши, но не одинарные — безопасно.
analystLine := "printf '%s\\n' '" + analystNDJSON + "'"
reviewerLine := "printf '%s\\n' '" + reviewerNDJSON + "'"
script := `#!/bin/sh
agent="$3"
case "$agent" in
analyst)
` + analystLine + `
;;
reviewer)
` + reviewerLine + `
;;
dev)
printf '%%s\n' '{"type":"text","part":{"text":"done"}}'
;;
*)
printf '%%s\n' '{"type":"text","part":{"text":"unknown agent"}}'
;;
esac
exit 0
`
bin := filepath.Join(dir, "opencode")
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write e2e fake opencode: %v", err)
agentOf := func(prompt string) string {
switch {
case strings.Contains(prompt, "Ты — аналитик"):
return "analyst"
case strings.Contains(prompt, "Ты — dev-агент"):
return "dev"
case strings.Contains(prompt, "Ты — ревьюер"):
return "reviewer"
default:
return "unknown"
}
}
return bin
verdictFor := func(agent string) string {
if v, ok := e2eAgentVerdicts[agent]; ok {
return v
}
return "unknown agent"
}
sessionID := func(path, suffix string) string {
return strings.TrimSuffix(strings.TrimPrefix(path, "/api/session/"), suffix)
}
assistantMsg := func(id, agent string) map[string]any {
ts := time.Now().UnixMilli()
return map[string]any{
"id": "m-" + id,
"type": "assistant",
"content": []map[string]any{{"type": "text", "text": verdictFor(agent)}},
"model": map[string]any{"providerID": "test", "id": "m"},
"time": map[string]any{"created": ts, "completed": ts},
}
}
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/session":
// v2 create: {model:{...}} → {data:{id}}
id := fmt.Sprintf("e2e-%d", len(sessions)+1)
mu.Lock()
sessions[id] = ""
mu.Unlock()
writeJSON(w, map[string]any{"data": map[string]any{"id": id}})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/prompt"):
// v2 durable admit: {prompt:{text}} → {data:{id,timeCreated}}
id := sessionID(r.URL.Path, "/prompt")
var req struct {
Prompt struct {
Text string `json:"text"`
} `json:"prompt"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
mu.Lock()
sessions[id] = agentOf(req.Prompt.Text)
mu.Unlock()
writeJSON(w, map[string]any{"data": map[string]any{"id": "p-" + id, "timeCreated": time.Now().UnixMilli()}})
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/message"):
// v2 поллинг: {data:[Session.Message]} (новейшие первыми).
id := sessionID(r.URL.Path, "/message")
mu.Lock()
agent := sessions[id]
mu.Unlock()
writeJSON(w, map[string]any{"data": []map[string]any{assistantMsg(id, agent)}})
case r.Method == http.MethodGet && r.URL.Path == "/api/session/active":
// сессий в активных дренажах нет → ответ завершён.
writeJSON(w, map[string]any{"data": map[string]any{}})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/interrupt"):
writeJSON(w, map[string]any{"data": map[string]any{"ok": true}})
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv.URL
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
// но с фейк-бинарём, подменённым на e2eFakeOpenCode. Возвращает App,
// каталог worktree и fake-канал (для проверки исходящих).
// но с фейк-сервером opencode (e2eFakeAPI), зарегистрированным в пуле.
// Возвращает App, каталог worktree и fake-канал (для проверки исходящих).
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
t.Helper()
dir := t.TempDir()
@@ -100,13 +155,16 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
}
t.Cleanup(func() { store.Close() })
bin := e2eFakeOpenCode(t, dir)
fakeURL := e2eFakeAPI(t)
pool := opencode.NewPool(worktree)
pool.RegisterExternal(worktree, fakeURL) // worktree обслуживается фейком
runner := &opencode.Runner{
Bin: bin,
PollInterval: 20 * time.Millisecond,
Pool: pool,
PollInterval: 5 * time.Millisecond,
IdleTimeout: 5 * time.Second,
HardTimeout: 30 * time.Second,
}
t.Cleanup(pool.Close)
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
coreCtx := core.New(store, an)
@@ -118,6 +176,7 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
CoreCtx: coreCtx,
}
a.Router = chat.NewRouter(a.handleIncoming)
fake.router = a.Router
if err := a.Router.Attach(fake); err != nil {
t.Fatalf("Attach fake channel: %v", err)
}
@@ -141,8 +200,9 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
// e2eChannel — минимальный fake-канал для перехвата исходящих
// и доставки входящих через роутер (как реальный канал).
type e2eChannel struct {
onMsg chat.Handler
sent []chat.Message
onMsg chat.Handler
sent []chat.Message
router *chat.Router
}
func (c *e2eChannel) Run(_ context.Context) error { return nil }
@@ -159,10 +219,21 @@ func (c *e2eChannel) Ask(_ context.Context, _ chat.Address, m chat.Message) erro
func (c *e2eChannel) Close() error { return nil }
// deliver отправляет входящее сообщение через роутер: ставит маршрут
// пользователя и вызывает app.handleIncoming (как в проде).
// пользователя и вызывает app.handleIncoming (как в проде). Так как роутер
// обрабатывает входящие асинхронно (процессор-горутина), deliver ждёт, пока
// обработка события завершится, — иначе тесты (сразу читающие состояние БД)
// гоняются с обработчиком.
func (c *e2eChannel) deliver(uid chat.UserID, text string) {
if c.onMsg != nil {
c.onMsg(chat.Incoming{UserID: uid, Address: chat.Address("u://" + string(uid)), Msg: chat.Message{Text: text}, Channel: c})
if c.onMsg == nil {
return
}
target := int64(0)
if c.router != nil {
target = c.router.Processed() + 1
}
c.onMsg(chat.Incoming{UserID: uid, Address: chat.Address("u://" + string(uid)), Msg: chat.Message{Text: text}, Channel: c})
if c.router != nil && !c.router.WaitProcessed(target) {
panic("e2e: роутер не обработал входящее за 30s")
}
}

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

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
// Router — единый диспетчер входящих из всех каналов и маршрутизатор исходящих.
@@ -18,6 +20,16 @@ type Router struct {
// Hook, вызываемый на каждое входящее событие (обычно → process_turn).
onUserMsg func(Incoming)
// Асинхронная обработка входящих: handleIncoming кладёт событие в канал,
// воркер-горутина последовательно вызывает onUserMsg. Благодаря этому
// long-poll цикл канала (Telegram) не блокируется на время долгого
// вызова аналитика и продолжает принимать новые сообщения.
incoming chan Incoming
// processed — число обработанных воркером событий (для синхронизации
// тестов с асинхронной очередью: WaitProcessed ждёт обработку события).
processed atomic.Int64
}
// NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего.
@@ -25,12 +37,39 @@ func NewRouter(onUserMsg func(Incoming)) *Router {
if onUserMsg == nil {
onUserMsg = func(Incoming) {}
}
return &Router{
r := &Router{
sessions: map[UserID]any{},
routes: map[UserID]Route{},
pending: map[UserID]PendingQ{},
onUserMsg: onUserMsg,
incoming: make(chan Incoming, 256),
}
go r.processLoop()
return r
}
// processLoop — воркер асинхронной обработки входящих (FIFO).
func (r *Router) processLoop() {
for inc := range r.incoming {
r.onUserMsg(inc)
r.processed.Add(1)
}
}
// Processed возвращает число обработанных воркером входящих событий.
func (r *Router) Processed() int64 { return r.processed.Load() }
// WaitProcessed ждёт, пока воркер обработает не меньше target событий
// (для синхронизации с асинхронной очередью в тестах).
func (r *Router) WaitProcessed(target int64) bool {
deadline := time.Now().Add(30 * time.Second)
for r.processed.Load() < target {
if time.Now().After(deadline) {
return false
}
time.Sleep(2 * time.Millisecond)
}
return true
}
// Attach регистрирует канал и подключает его к обработчику входящих.
@@ -59,7 +98,10 @@ func (r *Router) handleIncoming(inc Incoming) {
delete(r.pending, inc.UserID)
}
r.mu.Unlock()
r.onUserMsg(inc)
// Асинхронная обработка: кладём событие в очередь воркера и сразу
// возвращаемся, не блокируя вызывающий long-poll цикл канала.
r.incoming <- inc
}
// Send уведомляет пользователя через текущий маршрут. M1 (нет маршрута) — no-op,

View File

@@ -3,7 +3,9 @@ package chat
import (
"context"
"errors"
"sync"
"testing"
"time"
)
const (
@@ -13,13 +15,58 @@ const (
tui Address = "tui://local"
)
// fakeOnMsg — тест-колбэк, копящий входящие.
type fakeOnMsg struct{ got []Incoming }
// fakeOnMsg — тест-колбэк, копящий входящие. Т.к. Router теперь обрабатывает
// входящие асинхронно (воркер-горутина), доступ потокобезопасный, а ожидание
// нужного числа сообщений — через wait.
type fakeOnMsg struct {
mu sync.Mutex
ch chan struct{} // сигнал о появлении каждого нового входящего
got []Incoming
}
func (f *fakeOnMsg) h(inc Incoming) { f.got = append(f.got, inc) }
func newFakeOnMsg() *fakeOnMsg {
return &fakeOnMsg{ch: make(chan struct{}, 64)}
}
func (f *fakeOnMsg) h(inc Incoming) {
f.mu.Lock()
f.got = append(f.got, inc)
f.mu.Unlock()
f.ch <- struct{}{}
}
// wait блокируется, пока не наберётся n входящих. Возвращает false по таймауту.
func (f *fakeOnMsg) wait(n int) bool {
deadline := time.After(2 * time.Second)
for {
f.mu.Lock()
got := len(f.got)
f.mu.Unlock()
if got >= n {
return true
}
select {
case <-f.ch:
case <-deadline:
return false
}
}
}
func (f *fakeOnMsg) get(i int) Incoming {
f.mu.Lock()
defer f.mu.Unlock()
return f.got[i]
}
func (f *fakeOnMsg) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.got)
}
func TestRouter_AttachAndIncoming(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
@@ -28,10 +75,10 @@ func TestRouter_AttachAndIncoming(t *testing.T) {
}
tgCh.emit(uidA, tg, "привет")
if len(cb.got) != 1 {
t.Fatalf("handler got %d, want 1", len(cb.got))
if !cb.wait(1) {
t.Fatal("handler не получил входящее за таймаут")
}
got := cb.got[0]
got := cb.get(0)
if got.UserID != uidA || got.Address != tg || got.Msg.Text != "привет" {
t.Errorf("incoming = %+v", got)
}
@@ -53,12 +100,15 @@ func TestRouter_Send_NoRoute(t *testing.T) {
}
func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
_ = r.Attach(tgCh)
tgCh.emit(uidA, tg, "hi") // устанавливает маршрут
if !cb.wait(1) {
t.Fatal("маршрут не установился за таймаут")
}
if err := r.Send(context.Background(), uidA, Message{Text: "отв"}); err != nil {
t.Fatalf("Send: %v", err)
@@ -72,7 +122,7 @@ func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
}
func TestRouter_SwitchChannel_Continues(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
tuiCh := newFakeChannel(tui)
@@ -83,6 +133,9 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
tgCh.emit(uidA, tg, "hi")
// продолжил в GUI
tuiCh.emit(uidA, tui, "продолжаю тут")
if !cb.wait(2) {
t.Fatal("входящие не обработаны за таймаут")
}
if tgCh.sentCount() != 0 || tuiCh.sentCount() != 0 {
t.Fatal("до Send ничего не шлём")
}
@@ -98,11 +151,14 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
}
func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
_ = r.Attach(tgCh)
tgCh.emit(uidA, tg, "hi")
if !cb.wait(1) {
t.Fatal("первое входящее не обработано")
}
prompt := Message{Text: "Как зовут?", Options: []Option{{ID: "a", Label: "Анна"}}}
if err := r.Ask(context.Background(), uidA, prompt); err != nil {
@@ -118,13 +174,16 @@ func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
// ответ с того же адреса потребляет pending
tgCh.emit(uidA, tg, "Анна")
if !cb.wait(2) {
t.Fatal("ответ не обработан")
}
if _, ok := r.Pending(uidA); ok {
t.Fatal("pending должен быть закрыт после ответа")
}
if len(cb.got) != 2 {
t.Fatalf("handler got %d, want 2 (hi + ответ)", len(cb.got))
if cb.count() != 2 {
t.Fatalf("handler got %d, want 2 (hi + ответ)", cb.count())
}
if cb.got[1].Msg.QuestionID == "" {
if cb.get(1).Msg.QuestionID == "" {
t.Error("ответ должен нести QuestionID вопроса")
}
}
@@ -137,7 +196,7 @@ func TestRouter_Ask_NoRoute(t *testing.T) {
}
func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
tuiCh := newFakeChannel(tui)

View File

@@ -16,6 +16,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/kamelion/ratatoskr-go/internal/chat"
)
@@ -109,7 +110,7 @@ func (ch *Channel) handleUpdate(ctx context.Context, upd update) {
func (ch *Channel) sendMsg(chatID, text string) error {
body, _ := json.Marshal(map[string]string{
"chat_id": chatID,
"text": text[:min(len(text), 4000)],
"text": truncateUTF8(text, 4000),
"parse_mode": "HTML",
})
url := fmt.Sprintf(ch.apiURL+"sendMessage", ch.token)
@@ -155,10 +156,10 @@ func (ch *Channel) getUpdates(ctx context.Context, offset int64, timeout int) ([
// formatOutgoing собирает Message в HTML-строку: текст + нумерованные Options.
func formatOutgoing(m chat.Message) string {
if len(m.Options) == 0 {
return m.Text
return escapeHTML(m.Text)
}
var buf bytes.Buffer
buf.WriteString(m.Text)
buf.WriteString(escapeHTML(m.Text))
buf.WriteString("\n\n")
for i, opt := range m.Options {
buf.WriteString(fmt.Sprintf("<b>%d.</b> %s\n", i+1, escapeHTML(opt.Label)))
@@ -183,6 +184,18 @@ func escapeHTML(s string) string {
return buf.String()
}
// truncateUTF8 обрезает s до max байт, не разрывая UTF-8 последовательности.
func truncateUTF8(s string, max int) string {
if len(s) <= max {
return s
}
s = s[:max]
for len(s) > 0 && !utf8.ValidString(s) {
s = s[:len(s)-1]
}
return s
}
// ---- Telegram API types ----
type tgResponse struct {
@@ -202,4 +215,4 @@ type message struct {
Chat struct {
ID int64 `json:"id"`
} `json:"chat"`
}
}

View File

@@ -9,6 +9,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/kamelion/ratatoskr-go/internal/chat"
)
@@ -111,6 +112,36 @@ func TestSendWithOptions(t *testing.T) {
}
}
func TestFormatOutgoingEscapesText(t *testing.T) {
if got := formatOutgoing(chat.Message{Text: "2 < 3 & 4 > 1"}); got != "2 &lt; 3 &amp; 4 &gt; 1" {
t.Errorf("text escape = %q", got)
}
got := formatOutgoing(chat.Message{
Text: "a<b",
Options: []chat.Option{{ID: "x", Label: "l&l"}},
})
if !strings.Contains(got, "a&lt;b") || !strings.Contains(got, "l&amp;l") {
t.Errorf("options escape = %q", got)
}
}
func TestTruncateUTF8(t *testing.T) {
long := strings.Repeat("я", 5000)
tr := truncateUTF8(long, 4000)
if len(tr) != 4000 {
t.Fatalf("len = %d, want 4000", len(tr))
}
if !utf8.ValidString(tr) {
t.Fatal("truncated string is not valid UTF-8")
}
if got := truncateUTF8("привет", 4000); got != "привет" {
t.Fatalf("short text changed: %q", got)
}
if got := truncateUTF8("", 4000); got != "" {
t.Fatalf("empty text changed: %q", got)
}
}
func TestIncoming(t *testing.T) {
f := newFakeTG(t)
ch := New("TOKEN", time.Second)
@@ -143,4 +174,4 @@ func TestIncoming(t *testing.T) {
case <-ctx.Done():
t.Fatal("timeout ожидания входящего")
}
}
}

View File

@@ -1,6 +1,7 @@
package config
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -76,11 +77,75 @@ telegram:
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
}
if cfg.OpenCode.Serve.Enabled {
t.Errorf("serve.enabled = true, want false (дефолт)")
}
if cfg.OpenCode.Serve.Hostname != "127.0.0.1" {
t.Errorf("serve.hostname = %q, want 127.0.0.1", cfg.OpenCode.Serve.Hostname)
}
if cfg.OpenCode.Serve.Port != 4096 {
t.Errorf("serve.port = %d, want 4096", cfg.OpenCode.Serve.Port)
}
if cfg.Paths.Worktree != "./worktrees" {
t.Errorf("worktree = %q, want ./worktrees", cfg.Paths.Worktree)
}
}
func TestLoad_LogLevelDefault(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
yaml := `telegram:
token: "${TG_TOKEN}"
chat_id: "${TG_CHAT_ID}"
`
cfg, err := Load(writeCfg(t, yaml))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Log.Level != "info" {
t.Errorf("log.level = %q, want info (дефолт)", cfg.Log.Level)
}
if cfg.Log.Debug() {
t.Errorf("Debug() = true при уровне info, want false")
}
}
func TestLoad_LogLevelDebug(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
yaml := `telegram:
token: "${TG_TOKEN}"
chat_id: "${TG_CHAT_ID}"
log:
level: debug
`
cfg, err := Load(writeCfg(t, yaml))
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Log.Debug() {
t.Errorf("Debug() = false при level=debug, want true")
}
}
func TestLoad_LogLevelInvalid(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
yaml := `telegram:
token: "${TG_TOKEN}"
chat_id: "${TG_CHAT_ID}"
log:
level: warn
`
_, err := Load(writeCfg(t, yaml))
if !errors.Is(err, ErrInvalidFormat) {
t.Fatalf("Load: err = %v, want ErrInvalidFormat", err)
}
}
func TestLoad_OpenCodeConfigDir(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
@@ -186,14 +251,17 @@ func TestResolveExePaths_AbsoluteKept(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
absDB := filepath.Join(string(filepath.Separator), "data", "ratatoskr.db") // абсолютный для текущей ОС
absWt := filepath.Join(string(filepath.Separator), "worktrees")
// абсолютные пути «для текущей ОС»: на Windows слэш-относительный путь
// (\data\...) НЕ является абсолютным — нужен корень тома (C:\data\...).
root := filepath.VolumeName(os.TempDir()) + string(filepath.Separator)
absDB := filepath.Join(root, "data", "ratatoskr.db")
absWt := filepath.Join(root, "worktrees")
yaml := `telegram:
username: "${TG_TOKEN}"
chat_id: "${TG_CHAT_ID}"
paths:
db: "` + absDB + `"
worktree: "` + absWt + `"
db: '` + absDB + `'
worktree: '` + absWt + `'
`
cfg, err := Load(writeCfg(t, yaml))
if err != nil {

View File

@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"gopkg.in/yaml.v3"
@@ -133,6 +134,18 @@ func applyDefaults(cfg *Config) {
if fv.Kind() == reflect.String {
fv.SetString(meta.defaultVal)
}
// int (значение по умолчанию, напр. port)
if fv.Kind() == reflect.Int {
if n, err := strconv.Atoi(meta.defaultVal); err == nil {
fv.SetInt(int64(n))
}
}
// bool (по умолчанию false/true)
if fv.Kind() == reflect.Bool {
if b, err := strconv.ParseBool(meta.defaultVal); err == nil {
fv.SetBool(b)
}
}
})
}
@@ -156,6 +169,16 @@ func applyEnvOverrides(cfg *Config) {
if fv.Kind() == reflect.String {
fv.SetString(envVal)
}
if fv.Kind() == reflect.Int {
if n, err := strconv.Atoi(envVal); err == nil {
fv.SetInt(int64(n))
}
}
if fv.Kind() == reflect.Bool {
if b, err := strconv.ParseBool(envVal); err == nil {
fv.SetBool(b)
}
}
})
}
@@ -173,6 +196,10 @@ func walk(v reflect.Value, fn func(reflect.Value, fieldMeta)) {
fn(fv, collectMeta(f))
} else if fv.Type() == durType {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Int {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Bool {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Struct {
walk(fv, fn)
}

View File

@@ -10,6 +10,7 @@ package config
import (
"errors"
"fmt"
"strings"
"time"
)
@@ -40,8 +41,18 @@ type Config struct {
Chat ChatCfg `yaml:"chat"`
Paths PathsCfg `yaml:"paths"`
Update UpdateCfg `yaml:"update"`
Log LogCfg `yaml:"log"`
}
// LogCfg — уровень логирования. Level: "info" (по умолчанию) или "debug".
// debug включает отладочные логи (напр. все API-вызовы к opencode serve).
type LogCfg struct {
Level string `yaml:"level" default:"info"`
}
// Debug возвращает true, если включён отладочный уровень логирования.
func (l LogCfg) Debug() bool { return strings.EqualFold(l.Level, "debug") }
// GitCfg — источник репозиториев (для git clone).
type GitCfg struct {
BaseURL string `yaml:"base_url" env:"GIT_BASE_URL"`
@@ -78,6 +89,23 @@ type OpenCodeCfg struct {
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
PollMs Duration `yaml:"poll_ms" default:"2s"`
Serve ServeCfg `yaml:"serve"`
}
// ServeCfg — настройки постоянных opencode serve-процессов (API-режим).
// Runner ходит к serve по HTTP API (v1.17+, /api). ratatoskr сам поднимает
// по одному serve на каталог через Pool (Hash: hostname/port; служебный
// root-сервер в worktree живёт всё время app, остальные — лениво).
//
// Поля enabled/url оставлены для обратной совместимости конфига и сейчас не
// меняют поведение: serve обязателен для API-режима, сервер всегда
// спавнится пулом (hostname/port задают базовые значения).
type ServeCfg struct {
Enabled bool `yaml:"enabled" default:"false"`
URL string `yaml:"url" env:"OPENCODE_SERVE_URL"`
Hostname string `yaml:"hostname" default:"127.0.0.1"`
Port int `yaml:"port" default:"4096"`
Password string `yaml:"password" env:"OPENCODE_SERVE_PASSWORD"`
}
type ChatCfg struct {
@@ -99,5 +127,8 @@ func (c *Config) Validate() error {
if c.Telegram.ChatID == "" {
errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField))
}
if !c.Log.Debug() && !strings.EqualFold(c.Log.Level, "info") {
errs = append(errs, fmt.Errorf("%w: log.level (ожидается \"info\" или \"debug\")", ErrInvalidFormat))
}
return errors.Join(errs...)
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
@@ -18,6 +19,27 @@ type Core struct {
MaxQuestionsPerTurn int
// Live — опциональный просмотр живой сессии задачи (для /status N).
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).
@@ -66,7 +88,6 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
if task.Status == storage.StatusApproved {
return Result{
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -106,7 +127,6 @@ func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Re
default:
return Result{
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
Action: "send",
TaskID: taskID,
}, nil
}
@@ -119,16 +139,14 @@ func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
if err != nil {
return Result{}, err
}
task.Status = storage.StatusCollecting
if err := c.Store.ClearHistory(ctx, task.ID); err != nil {
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
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{
Reply: greeting,
Action: "greeting",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -140,13 +158,11 @@ func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
if err != nil {
return Result{}, err
}
task.Status = storage.StatusCancelled
if err := c.Store.UpdateTask(ctx, task); err != nil {
if err := c.setStatus(ctx, task, storage.StatusCancelled); err != nil {
return Result{}, err
}
return Result{
Reply: "🚫 Отменил.",
Action: "drop",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -161,7 +177,6 @@ func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
if task.Status == storage.StatusReady {
return Result{
Reply: "Напишите «создавай» — или правьте текст.",
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -191,8 +206,7 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/retry 5`.",
Action: "send",
Reply: "Укажите номер задачи: `/retry 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -204,21 +218,18 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
if storage.IsTerminal(task.Status) {
return Result{
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
}
task.Status = storage.StatusCollecting
if err := c.Store.ClearHistory(ctx, id); err != nil {
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
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{
Reply: "Задача перезапущена. Опишите, что меняем:",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -229,8 +240,7 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/status 5`.",
Action: "send",
Reply: "Укажите номер задачи: `/status 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -246,7 +256,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
}
return Result{
Reply: reply,
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -257,8 +266,7 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/continue 5`.",
Action: "send",
Reply: "Укажите номер задачи: `/continue 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -268,7 +276,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
return Result{
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
". Резюм сессии — пока не реализован.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -277,21 +284,18 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
// notFoundReply формирует reply для ненайденной задачи.
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
return Result{
Reply: "Задача #" + itoa(id) + " не найдена.",
Action: "send",
Reply: "Задача #" + itoa(id) + " не найдена.",
}, nil
}
// handleConsent одобряет задачу: ready → approved (финальное одобрение,
// после которого воркер забирает задачу на выполнение).
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
task.Status = storage.StatusApproved
if err := c.Store.UpdateTask(ctx, task); err != nil {
if err := c.setStatus(ctx, task, storage.StatusApproved); err != nil {
return Result{}, err
}
return Result{
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
Action: "created:" + itoa(task.ID),
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -299,6 +303,7 @@ func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, e
// handleEdit — правка черновика в фазе ready → снова сбор + аналитик.
func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string) (Result, error) {
from := task.Status
task.Status = storage.StatusCollecting
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
return Result{}, err
@@ -306,6 +311,7 @@ func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string)
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status})
return c.runDecide(ctx, task, false)
}
@@ -314,10 +320,12 @@ func (c *Core) handleTurn(ctx context.Context, task *storage.Task, text string)
// переход draft→collecting нужно персистить до вызова аналитика,
// иначе propose сделает draft→ready (невалидно)
if task.Status != storage.StatusCollecting {
from := task.Status
task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status})
}
if text != "" {
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
@@ -333,6 +341,23 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
if err != nil {
return Result{}, err
}
if !force && c.MaxTurns > 0 {
userTurns := 0
for _, h := range history {
if h.Role == "user" {
userTurns++
}
}
if userTurns > c.MaxTurns {
return Result{
Reply: "Превышен лимит ходов сбора (" + itoa(int64(c.MaxTurns)) + "). Используйте /skip чтобы сформулировать черновик, или /start для новой задачи.",
TaskID: task.ID,
Status: task.Status,
}, nil
}
}
msgs := make([]Message, 0, len(history))
for _, h := range history {
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
@@ -345,47 +370,42 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
switch decision.Phase {
case "abort":
task.Status = storage.StatusAborted
if err := c.Store.UpdateTask(ctx, task); err != nil {
if err := c.setStatus(ctx, task, storage.StatusAborted); err != nil {
return Result{}, err
}
reply := decision.ChatReply
if reply == "" {
reply = "Недостаточно данных. Начните заново (/start)."
}
return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
case "propose", "ready":
// применяем черновик (для ready — текущий, без изменений)
applyDraft(task, decision.Draft)
// E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить.
if len(task.EffectiveRepos()) == 0 {
task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil {
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
return Result{}, err
}
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
return Result{Reply: reply, Action: "send", 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.Store.UpdateTask(ctx, task); err != nil {
if err := c.setStatus(ctx, task, storage.StatusReady); err != nil {
return Result{}, err
}
return Result{
Reply: formatSummary(*task),
Action: "summary",
TaskID: task.ID,
Status: task.Status,
}, nil
default: // "ask"
applyDraft(task, decision.Draft)
task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil {
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
return Result{}, err
}
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
}

View File

@@ -2,6 +2,7 @@ package core
import (
"context"
"strings"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
@@ -44,13 +45,11 @@ func TestStartCreatesCollecting(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/start")
_, err := c.ProcessTurn(ctx, id, "/start")
if err != nil {
t.Fatalf("ProcessTurn /start: %v", err)
}
if res.Action != "greeting" {
t.Fatalf("action = %q, want greeting", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCollecting {
t.Fatalf("status = %s, want collecting", task.Status)
@@ -61,13 +60,11 @@ func TestCancelSetsCancelled(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/cancel")
_, err := c.ProcessTurn(ctx, id, "/cancel")
if err != nil {
t.Fatalf("ProcessTurn /cancel: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCancelled {
t.Fatalf("status = %s, want cancelled", task.Status)
@@ -84,13 +81,11 @@ func TestSingleTurnPropose(t *testing.T) {
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
_, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusReady {
t.Fatalf("status = %s, want ready", task.Status)
@@ -114,9 +109,7 @@ func TestAskReturnsQuestions(t *testing.T) {
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
t.Fatalf("reply = %q", res.Reply)
}
@@ -133,13 +126,11 @@ func TestAbortReturnsDrop(t *testing.T) {
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "привет")
_, err := c.ProcessTurn(ctx, id, "привет")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusAborted {
t.Fatalf("status = %s, want aborted", task.Status)
@@ -153,13 +144,11 @@ func TestConsentInReady(t *testing.T) {
id := mkTask(t, store, ctx, "u1")
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
res, err := c.ProcessTurn(ctx, id, "создавай")
_, err := c.ProcessTurn(ctx, id, "создавай")
if err != nil {
t.Fatalf("ProcessTurn создавай: %v", err)
}
if res.Action != "created:"+itoa(id) {
t.Fatalf("action = %q, want created:%d", res.Action, id)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusApproved {
t.Fatalf("status после создавай = %s, want approved", task.Status)
@@ -176,13 +165,11 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
_, _ = c.ProcessTurn(ctx, id, "сделай X")
// в ready пишем правку, не согласие
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
_, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
if err != nil {
t.Fatalf("ProcessTurn edit: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
@@ -194,25 +181,59 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
func TestRetryNotFound(t *testing.T) {
c, ctx, _ := setupCore(t, nil)
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
_, err := c.ProcessTurn(ctx, 999, "/retry 999")
if err != nil {
t.Fatalf("ProcessTurn /retry: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
}
func TestUnknownCommand(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/bogus")
_, err := c.ProcessTurn(ctx, id, "/bogus")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
func TestMaxTurnsBlocksExcessCollection(t *testing.T) {
var calls int
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
calls++
return Decision{Phase: "ask", ChatReply: "Ещё вопрос"}, nil
})
c.MaxTurns = 2
id := mkTask(t, store, ctx, "u1")
_, err := c.ProcessTurn(ctx, id, "первый факт")
if err != nil {
t.Fatalf("1-й ход: %v", err)
}
_, err = c.ProcessTurn(ctx, id, "второй факт")
if err != nil {
t.Fatalf("2-й ход: %v", err)
}
res, err := c.ProcessTurn(ctx, id, "третий факт")
if err != nil {
t.Fatalf("3-й ход: %v", err)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
if !strings.Contains(res.Reply, "лимит") {
t.Fatalf("reply = %q, want упоминание лимита", res.Reply)
}
// /skip — принудительный вызов, лимит не мешает
_, err = c.ProcessTurn(ctx, id, "/skip")
if err != nil {
t.Fatalf("/skip: %v", err)
}
if calls != 3 {
t.Fatalf("decide calls after /skip = %d, want 3", calls)
}
}
@@ -229,4 +250,4 @@ func TestParseTaskID(t *testing.T) {
if _, ok := parseTaskID("abc"); ok {
t.Fatal("parseTaskID(abc) should be invalid")
}
}
}

View File

@@ -15,10 +15,10 @@ type Message struct {
// Decision — вердикт аналитика.
type Decision struct {
Phase string // "ask" | "propose" | "ready" | "abort"
Draft storage.Task // обновлённые поля черновика
Questions []string // вопросы для phase=ask
ChatReply string // ответ пользователю
Phase string // "ask" | "propose" | "ready" | "abort"
Draft storage.Task // обновлённые поля черновика
Questions []string // вопросы для phase=ask
ChatReply string // ответ пользователю
}
// Decider — интерфейс для вызова аналитика (opencode).
@@ -29,7 +29,6 @@ type Decider interface {
// Result — результат одного хода.
type Result struct {
Reply string
Action string // send | summary | created:N | abort | drop | greeting
TaskID int64
Status storage.Status
}
@@ -46,4 +45,4 @@ func isConsent(text string) bool {
}
}
return false
}
}

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})
}

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

@@ -0,0 +1,65 @@
package events
import (
"strings"
"sync"
)
// LogLine — строка лога для панели «Логи».
type LogLine struct {
Level string
Text string
}
func (LogLine) _event() {}
// 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{Level:"log"}.
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: "log", Text: line})
}
}
w.buf.Reset()
w.buf.WriteString(data)
return len(p), nil
}

View File

@@ -0,0 +1,76 @@
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 != "log" {
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)
}
}

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))
}
}

331
internal/opencode/client.go Normal file
View File

@@ -0,0 +1,331 @@
package opencode
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
// Client — HTTP-взаимодействие с одним opencode serve (v2 HTTP API).
//
// Пути v2 начинаются с префикса /api (см. README, минимальная версия opencode):
// - POST /api/session создать сессию {model:{...}} → {data: Session.Info}
// - POST /api/session/{id}/prompt отправить промпт {prompt:{text}} →
// НЕБЛОКИРУЮЩЕ (admit) → {data: Admitted}
// - GET /api/session/{id}/message?order=desc → {data:[Message,...]}
// - POST /api/session/{id}/interrupt прервать активный ответ (204)
// - GET /api/session/active активные дренажи → {data:{sessionID:...}}
//
// Prompt не блокирует: вердикт собирается поллингом из content[].type=="text"
// новых assistant-сообщений (см. Runner.awaitVerdict).
type Client struct {
BaseURL string // http://host:port (без завершающего слеша)
Password string // basic auth (username "opencode")
Debug bool // включать отладочные логи API-вызовов (log.level=debug)
http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send)
}
// ClientErr — классы ошибок клиента.
type ClientErr struct {
Op string // "connect" | "create" | "prompt" | "messages" | "active" | "abort"
Err error
}
func (e *ClientErr) Error() string { return fmt.Sprintf("opencode api %s: %v", e.Op, e.Err) }
func (e *ClientErr) Unwrap() error { return e.Err }
func (c *Client) defaults() {
if c.http == nil {
c.http = &http.Client{Timeout: 30 * time.Second}
}
}
// do выполняет запрос через c.http и возвращает тело при 2xx.
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]byte, error) {
c.defaults()
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
if err != nil {
return nil, &ClientErr{Op: "connect", Err: err}
}
if c.Password != "" {
req.SetBasicAuth("opencode", c.Password)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Debug {
log.Printf("opencode api %s -> %s %s%s", op, method, c.BaseURL, path)
if len(body) > 0 {
log.Printf("opencode api %s request body: %s", op, truncateStr(string(body), 5000))
}
}
resp, err := c.http.Do(req)
if err != nil {
return nil, &ClientErr{Op: "connect", Err: err}
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, &ClientErr{Op: "connect", Err: err}
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
if c.Debug {
log.Printf("opencode api %s response: status %d: %s", op, resp.StatusCode, truncateStr(string(b), 1000))
}
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
}
if c.Debug {
log.Printf("opencode api %s response (%d bytes): %s", op, len(b), truncateStr(string(b), 5000))
}
return b, nil
}
// ModelRef — ссылка на модель (аналог v2 Model.Ref: {providerID, id, variant?}).
// providerID — имя провайдера из конфига opencode, id — идентификатор модели.
type ModelRef struct {
ProviderID string `json:"providerID"`
ID string `json:"id"`
Variant string `json:"variant,omitempty"`
}
// String возвращает каноничное представление "provider/id[/variant]".
func (m *ModelRef) String() string {
if m == nil {
return ""
}
if m.Variant != "" {
return m.ProviderID + "/" + m.ID + "/" + m.Variant
}
return m.ProviderID + "/" + m.ID
}
// CreateSession создаёт новую сессию и возвращает её id. model != nil —
// хардпин модели (top-level "model" из конфига opencode), чтобы не зависеть
// от fallback-логики выбора модели в самом opencode.
func (c *Client) CreateSession(ctx context.Context, model *ModelRef) (string, error) {
payload := map[string]any{}
if model != nil {
payload["model"] = model
}
body, _ := json.Marshal(payload)
raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", body)
if err != nil {
return "", err
}
var out struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
if out.Data.ID == "" {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
}
return out.Data.ID, nil
}
// Admitted — результат admit промпта (SessionInput.Admitted).
type Admitted struct {
ID string // id user-сообщения
TimeCreated int64 // epoch ms создания промпта (граница «новых» ответов)
}
// Prompt неблокирующе отправляет промпт в сессию (durable admit) и возвращает
// границу времени, с которой следует считать assistant-сообщения «новыми».
func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) (*Admitted, error) {
payload := map[string]any{
"prompt": map[string]string{"text": prompt},
}
body, _ := json.Marshal(payload)
raw, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", body)
if err != nil {
return nil, err
}
var out struct {
Data struct {
ID string `json:"id"`
TimeCreated int64 `json:"timeCreated"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
if out.Data.ID == "" {
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("пустой id промпта в ответе")}
}
return &Admitted{ID: out.Data.ID, TimeCreated: out.Data.TimeCreated}, nil
}
// v2Message — минимальная проекция Session.Message (tagged union: тип в "type").
// Поле "role" в v2 отсутствует; assistant определяется по type=="assistant".
type v2Message struct {
ID string `json:"id"`
Type string `json:"type"` // "assistant" | "user" | "tool" | "system" | ...
Content []v2Part `json:"content"`
Model *ModelRef `json:"model"`
Finish string `json:"finish,omitempty"`
Error *v2Error `json:"error,omitempty"`
Time v2Time `json:"time"`
}
type v2Part struct {
Type string `json:"type"` // "text" | "reasoning" | "tool" | ...
Text string `json:"text"`
}
type v2Time struct {
Created *int64 `json:"created"`
Completed *int64 `json:"completed"`
}
type v2Error struct {
Type string `json:"type"`
Message string `json:"message"`
}
// finished — завершено ли assistant-сообщение (ответ агента закончен).
func (m *v2Message) finished() bool {
if m == nil {
return false
}
if m.Error != nil {
return true
}
if m.Finish != "" {
return true
}
return m.Time.Completed != nil && *m.Time.Completed > 0
}
// Messages возвращает сообщения сессии (новейшие первыми, до 200 за запрос).
func (c *Client) Messages(ctx context.Context, sessionID string) ([]v2Message, error) {
raw, err := c.do(ctx, http.MethodGet, "/api/session/"+sessionID+"/message?order=desc&limit=200", "messages", nil)
if err != nil {
return nil, err
}
var out struct {
Data []v2Message `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, &ClientErr{Op: "messages", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
return out.Data, nil
}
// Active возвращает true, если сессия ещё обрабатывается (есть в активных
// дренажах этого serve). Сессии вне списка считаются завершёнными.
func (c *Client) Active(ctx context.Context, sessionID string) (bool, error) {
raw, err := c.do(ctx, http.MethodGet, "/api/session/active", "active", nil)
if err != nil {
return false, err
}
var out struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return false, &ClientErr{Op: "active", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
if out.Data == nil {
return false, nil
}
_, ok := out.Data[sessionID]
return ok, nil
}
// Interrupt прерывает активный ответ сессии (аналог v1 abort).
func (c *Client) Interrupt(ctx context.Context, sessionID string) error {
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/interrupt", "abort", nil)
return err
}
// assistantSince фильтрует assistant-сообщения, созданные не раньше since
// (порядок сохраняется — как пришёл из API, новейшие первыми).
func assistantSince(msgs []v2Message, since int64) []*v2Message {
out := make([]*v2Message, 0, len(msgs))
for i := range msgs {
m := &msgs[i]
if m.Type != "assistant" {
continue
}
if m.Time.Created == nil || *m.Time.Created < since {
continue
}
out = append(out, m)
}
return out
}
// textParts считает text-парты в одном assistant-сообщении (для прогресса).
func textParts(m *v2Message) int {
n := 0
for _, p := range m.Content {
if p.Type == "text" && p.Text != "" {
n++
}
}
return n
}
// newestAssistant возвращает самое новое assistant-сообщение (из фильтра) и
// суммарное число text-партов. since — граница времени (epoch ms).
func newestAssistant(msgs []v2Message, since int64) (*v2Message, int) {
ass := assistantSince(msgs, since)
var newest *v2Message
count := 0
for _, m := range ass {
count += textParts(m)
if newest == nil || *m.Time.Created > *newest.Time.Created {
newest = m
}
}
return newest, count
}
// progressOf — «живой» прогресс новых assistant-сообщений: число контент-партов
// (text/reasoning/tool) + суммарная длина их текста. Растёт во время стриминга,
// когда один и тот же парт увеличивается (и при reasoning), — это и есть
// сигнал, что LLM работает, а не висит.
func progressOf(msgs []v2Message, since int64) (parts, textLen int) {
for _, m := range assistantSince(msgs, since) {
for _, p := range m.Content {
parts++
if p.Type == "text" || p.Type == "reasoning" {
textLen += len(p.Text)
}
}
}
return
}
// assistantText объединяет text-парты новых assistant-сообщений в хронологическом
// порядке (сообщения приходят новейшими первыми → идём с конца).
func assistantText(msgs []v2Message, since int64) []string {
ass := assistantSince(msgs, since)
texts := make([]string, 0, len(ass))
for i := len(ass) - 1; i >= 0; i-- {
for _, p := range ass[i].Content {
if p.Type == "text" && p.Text != "" {
texts = append(texts, p.Text)
}
}
}
return texts
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@@ -0,0 +1,301 @@
package opencode
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeAPIServer — минимальный фейк opencode serve v2 HTTP API (пути /api/*).
//
// Сценарии:
// - нормальный: Prompt ставит active=false и в messages кладётся финальное
// assistant-сообщение (verdictText) → Runner собирает вердикт;
// - blockPrompt: «агент завис» — active=true всегда, сообщений нет → idle abort;
// - failCreate / failMessages — имитация ошибок;
// - growStream: стрим одного растущего парта — текст/reasoning растёт с
// каждым опросом GET /message (streamPolls раз), active=true, затем
// active=false + финальное завершённое сообщение.
type fakeAPIServer struct {
sessionID string
created bool
active bool
blockPrompt bool
messages []v2Message
verdictText string
failCreate bool
failMessages bool
createdModel *ModelRef // модель, полученная на POST /api/session
promptCalls int
// streamGrow: стрим одного растущего парта — текст/reasoning растёт с
// каждым опросом GET /message, active=true, пока messageCalls не дойдёт до
// streamPolls; затем active=false + финальное завершённое сообщение.
streamGrow bool
streamReasoning bool // растущий парт — reasoning вместо text
streamPolls int // сколько опросов длится «стрим» до завершения
messageCalls int
}
func (f *fakeAPIServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if f.failCreate {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
var in struct {
Model *ModelRef `json:"model"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
f.createdModel = in.Model
f.sessionID = "sess-fake"
f.created = true
writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}})
})
mux.HandleFunc("/api/session/active", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
data := map[string]any{}
if f.active && f.sessionID != "" {
data[f.sessionID] = map[string]any{"type": "running"}
}
writeJSON(w, map[string]any{"data": data})
})
mux.HandleFunc("/api/session/{id}/prompt", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
f.promptCalls++
if f.blockPrompt {
// «зависший» агент: активен, но сообщений не появляется.
f.active = true
} else {
f.active = false
}
writeJSON(w, map[string]any{"data": map[string]any{
"id": "msg_1",
"sessionID": f.sessionID,
"timeCreated": time.Now().UnixMilli(),
}})
})
mux.HandleFunc("/api/session/{id}/interrupt", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
f.active = false
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/api/session/{id}/message", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if f.failMessages {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
if f.streamGrow {
f.messageCalls++
now := time.Now().UnixMilli()
done := f.messageCalls >= f.streamPolls
msg := v2Message{ID: "msg_stream", Type: "assistant", Time: v2Time{Created: &now}}
switch {
case done:
msg.Content = []v2Part{{Type: "text", Text: "done-stream"}}
msg.Finish = "end_turn"
msg.Time.Completed = &now
f.active = false
case f.streamReasoning:
msg.Content = []v2Part{{Type: "reasoning", Text: strings.Repeat("r", f.messageCalls)}}
f.active = true
default:
msg.Content = []v2Part{{Type: "text", Text: strings.Repeat("x", f.messageCalls)}}
f.active = true
}
writeJSON(w, map[string]any{"data": []v2Message{msg}})
return
}
msgs := f.messages
if msgs == nil && f.verdictText != "" && !f.blockPrompt {
msgs = []v2Message{f.assistantMsg(f.verdictText)}
}
if msgs == nil {
msgs = []v2Message{}
}
writeJSON(w, map[string]any{"data": msgs})
})
return mux
}
// assistantMsg строит завершённое assistant-сообщение с text-партом.
func (f *fakeAPIServer) assistantMsg(text string) v2Message {
now := time.Now().UnixMilli()
return v2Message{
ID: "msg_a",
Type: "assistant",
Content: []v2Part{{Type: "text", Text: text}},
Finish: "end_turn",
Time: v2Time{Created: &now, Completed: &now},
}
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// fakeClient — клиент к фейк-серверу.
func fakeClient(t *testing.T, f *fakeAPIServer) *Client {
t.Helper()
ts := httptest.NewServer(f.handler())
t.Cleanup(ts.Close)
return &Client{BaseURL: ts.URL}
}
func TestClient_CreateSession(t *testing.T) {
f := &fakeAPIServer{}
c := fakeClient(t, f)
id, err := c.CreateSession(context.Background(), nil)
if err != nil {
t.Fatalf("CreateSession err: %v", err)
}
if id != "sess-fake" {
t.Errorf("id = %q, want sess-fake", id)
}
if f.createdModel != nil {
t.Errorf("createdModel = %+v, want nil", f.createdModel)
}
}
func TestClient_CreateSessionHardpinsModel(t *testing.T) {
want := &ModelRef{ProviderID: "tokentool", ID: "deepseek/deepseek-v4-flash-0731"}
f := &fakeAPIServer{}
c := fakeClient(t, f)
if _, err := c.CreateSession(context.Background(), want); err != nil {
t.Fatalf("CreateSession err: %v", err)
}
if f.createdModel == nil || f.createdModel.ProviderID != want.ProviderID || f.createdModel.ID != want.ID {
t.Errorf("createdModel = %+v, want %+v", f.createdModel, want)
}
}
func TestClient_CreateSessionFail(t *testing.T) {
c := fakeClient(t, &fakeAPIServer{failCreate: true})
if _, err := c.CreateSession(context.Background(), nil); err == nil {
t.Fatal("CreateSession должен упасть при 500, а не nil")
}
}
func TestClient_Prompt(t *testing.T) {
c := fakeClient(t, &fakeAPIServer{})
adm, err := c.Prompt(context.Background(), "sess-fake", "почини x")
if err != nil {
t.Fatalf("Prompt err: %v", err)
}
if adm.ID != "msg_1" {
t.Errorf("adm.ID = %q, want msg_1", adm.ID)
}
if adm.TimeCreated == 0 {
t.Error("adm.TimeCreated = 0, want epoch ms")
}
}
func TestClient_Messages(t *testing.T) {
now := time.Now().UnixMilli()
f := &fakeAPIServer{messages: []v2Message{{
ID: "msg_a", Type: "assistant",
Content: []v2Part{{Type: "text", Text: "a"}, {Type: "reasoning", Text: "x"}},
Finish: "end_turn",
Time: v2Time{Created: &now, Completed: &now},
}}}
c := fakeClient(t, f)
msgs, err := c.Messages(context.Background(), "sess-fake")
if err != nil {
t.Fatalf("Messages err: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("len(msgs) = %d, want 1", len(msgs))
}
if !msgs[0].finished() {
t.Error("сообщение должно быть finished (Finish задан)")
}
}
func TestClient_Active(t *testing.T) {
f := &fakeAPIServer{active: true, sessionID: "sess-fake"}
c := fakeClient(t, f)
ok, err := c.Active(context.Background(), "sess-fake")
if err != nil {
t.Fatalf("Active err: %v", err)
}
if !ok {
t.Error("Active = false, want true")
}
ok, _ = c.Active(context.Background(), "sess-other")
if ok {
t.Error("Active(чужой) = true, want false")
}
}
func TestClient_Interrupt(t *testing.T) {
c := fakeClient(t, &fakeAPIServer{})
if err := c.Interrupt(context.Background(), "sess-fake"); err != nil {
t.Fatalf("Interrupt err: %v", err)
}
}
func Test_newestAssistant(t *testing.T) {
older := time.Now().Add(-time.Minute).UnixMilli()
newer := time.Now().UnixMilli()
msgs := []v2Message{
{ID: "a", Type: "assistant", Content: []v2Part{{Type: "text", Text: "x"}}, Time: v2Time{Created: &newer}},
{ID: "b", Type: "user", Time: v2Time{Created: &newer}},
{ID: "c", Type: "assistant", Content: []v2Part{{Type: "text", Text: "y"}}, Time: v2Time{Created: &older}},
}
cur, count := newestAssistant(msgs, older)
if cur == nil || cur.ID != "a" {
t.Errorf("newest = %v, want a", cur)
}
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
texts := assistantText(msgs, older)
if len(texts) != 2 || texts[0] != "y" || texts[1] != "x" {
t.Errorf("assistantText order = %v, want [y x]", texts)
}
}
func Test_parseModelString(t *testing.T) {
m := parseModelString("tokentool/deepseek/deepseek-v4-flash-0731")
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
t.Errorf("parse = %+v, want tokentool/deepseek-v4-flash-0731", m)
}
if parseModelString("onlyprovider") != nil {
t.Error("parse без '/' должен вернуть nil")
}
if parseModelString("") != nil {
t.Error("parse пустой должен вернуть nil")
}
}
func TestClientErr_Unwrap(t *testing.T) {
ce := &ClientErr{Op: "prompt", Err: errors.New("boom")}
var target *ClientErr
if !errors.As(ce, &target) {
t.Fatal("expected *ClientErr")
}
}

176
internal/opencode/config.go Normal file
View File

@@ -0,0 +1,176 @@
package opencode
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// Чтение top-level "model" из эффективного конфига opencode.
//
// Зачем: ratatoskr хардпинит модель в сессии (CreateSession), чтобы не зависеть
// от fallback-логики opencode. Если в конфиге модель не задана (или конфиг
// написан по старой v1-схеме — npm/options, которые v2 молча игнорирует),
// opencode сам выберет «дефолтную» модельную запись, и это может оказаться не
// той моделью. Поэтому мы явно логируем предупреждение (класс O5 WARN).
// opencodeConfigPath определяет путь к конфигу opencode, который видит
// serve-процесс этого пула (см. README): (1) явный OPENCODE_CONFIG из Server
// или окружения процесса, (2) OPENCODE_CONFIG_DIR / глобальный каталог
// ~/.config/opencode. Возвращает "" если ничего не найдено.
func opencodeConfigPath(cfgFile, cfgDir string) string {
// (1) явный файл конфига — Server.Config или env OPENCODE_CONFIG.
p := cfgFile
if p == "" {
p = os.Getenv("OPENCODE_CONFIG")
}
if p != "" {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
// (2) каталог конфигов.
dir := cfgDir
if dir == "" {
dir = os.Getenv("OPENCODE_CONFIG_DIR")
}
if dir == "" {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return ""
}
dir = filepath.Join(home, ".config", "opencode")
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
dir = filepath.Join(x, "opencode")
}
}
for _, name := range []string{"opencode.json", "opencode.jsonc"} {
cand := filepath.Join(dir, name)
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
return cand
}
}
return ""
}
// ReadModelRef извлекает top-level "model" из конфига opencode и возвращает
// его как ModelRef. Модель не задана — вернёт (nil, nil); ошибка чтения/парсинга
// возвращается (вызывающий логирует warning и продолжает без хардпина).
func ReadModelRef(cfgFile, cfgDir string) (*ModelRef, error) {
path := opencodeConfigPath(cfgFile, cfgDir)
if path == "" {
return nil, nil
}
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: читать %s: %w", path, err)
}
doc := struct {
Model json.RawMessage `json:"model"`
}{}
if err := json.Unmarshal(stripJSONC(b), &doc); err != nil {
return nil, fmt.Errorf("config: парсить %s: %w", path, err)
}
if len(doc.Model) == 0 || strings.TrimSpace(string(doc.Model)) == "null" {
return nil, nil
}
// "model" может быть строкой "provider/id" или объектом {providerID, id}.
var s string
if err := json.Unmarshal(doc.Model, &s); err == nil {
ref := parseModelString(s)
if ref == nil {
return nil, fmt.Errorf("config: некорректная model %q в %s (ожидается provider/id)", s, path)
}
return ref, nil
}
var ref ModelRef
if err := json.Unmarshal(doc.Model, &ref); err != nil {
return nil, fmt.Errorf("config: некорректная model в %s", path)
}
if ref.ProviderID == "" || ref.ID == "" {
return nil, fmt.Errorf("config: model без providerID/id в %s", path)
}
return &ref, nil
}
// parseModelString разбирает "provider/id" (как ModelV2.parse: провайдер — всё
// до первого '/', id — остаток). Возвращает nil при пустой/некорректной строке.
func parseModelString(s string) *ModelRef {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
i := strings.IndexByte(s, '/')
if i <= 0 || i == len(s)-1 {
return nil
}
return &ModelRef{ProviderID: s[:i], ID: s[i+1:]}
}
// stripJSONC удаляет // и /* */ комментарии (вне строк), сохраняя позиции
// переводов строк, чтобы json.Unmarshal не споткнулся о trailing-комма.
func stripJSONC(b []byte) []byte {
out := make([]byte, 0, len(b))
inStr := false
esc := false
i := 0
for i < len(b) {
c := b[i]
if inStr {
out = append(out, c)
if esc {
esc = false
} else if c == '\\' {
esc = true
} else if c == '"' {
inStr = false
}
i++
continue
}
switch {
case c == '"':
inStr = true
out = append(out, c)
i++
case c == '/' && i+1 < len(b) && b[i+1] == '/':
for i < len(b) && b[i] != '\n' {
i++
}
if i < len(b) {
out = append(out, '\n')
i++
}
case c == '/' && i+1 < len(b) && b[i+1] == '*':
i += 2
for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') {
i++
}
i += 2
default:
out = append(out, c)
i++
}
}
return dropTrailingCommas(out)
}
// dropTrailingCommas убирает запятые перед '}' / ']' (допускаются в JSONC).
func dropTrailingCommas(b []byte) []byte {
out := make([]byte, 0, len(b))
for i := 0; i < len(b); i++ {
if b[i] == ',' {
j := i + 1
for j < len(b) && (b[j] == ' ' || b[j] == '\t' || b[j] == '\n' || b[j] == '\r') {
j++
}
if j < len(b) && (b[j] == '}' || b[j] == ']') {
continue
}
}
out = append(out, b[i])
}
return out
}

View File

@@ -0,0 +1,85 @@
package opencode
import (
"os"
"path/filepath"
"testing"
)
func TestReadModelRef_String(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "opencode.jsonc")
// конфиг с комментариями и trailing-запятыми (JSONC).
src := `{
// комментарий
"model": "tokentool/deepseek/deepseek-v4-flash-0731", /* и блочный */
"provider": {
"tokentool": {"api": {"type": "aisdk", "package": "@ai-sdk/openai-compatible", "url": "https://x"}},
},
}`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
m, err := ReadModelRef(path, "")
if err != nil {
t.Fatalf("ReadModelRef err: %v", err)
}
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
t.Errorf("model = %+v, want tokentool/deepseek-v4-flash-0731", m)
}
}
func TestReadModelRef_Object(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "opencode.json")
src := `{"model": {"providerID": "tokentool", "id": "deepseek/deepseek-v4-flash-0731"}}`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
m, err := ReadModelRef(path, "")
if err != nil {
t.Fatalf("ReadModelRef err: %v", err)
}
if m == nil || m.ID != "deepseek/deepseek-v4-flash-0731" {
t.Errorf("model = %+v, want object-форма", m)
}
}
func TestReadModelRef_Missing(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "opencode.json")
src := `{"provider": {}}`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
m, err := ReadModelRef(path, "")
if err != nil {
t.Fatalf("ReadModelRef err: %v", err)
}
if m != nil {
t.Errorf("model = %+v, want nil (model не задан)", m)
}
}
func TestReadModelRef_NoFile(t *testing.T) {
dir := t.TempDir()
m, err := ReadModelRef(filepath.Join(dir, "nope.json"), dir)
if err != nil {
t.Fatalf("ReadModelRef err: %v", err)
}
if m != nil {
t.Errorf("model = %+v, want nil", m)
}
}
func TestReadModelRef_Bad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "opencode.json")
src := `{"model": 12345}`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := ReadModelRef(path, ""); err == nil {
t.Error("ReadModelRef должен упасть на некорректной model")
}
}

View File

@@ -4,7 +4,6 @@
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
// - ExtractJSON: fenced ```json``` → первый {...}
// - Run/ResumeDev: запуск процесса с idle/hard timeout по opencode.db
package opencode
import (
@@ -14,9 +13,8 @@ import (
)
var (
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`)
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
)
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
@@ -76,11 +74,12 @@ func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
return nil, false
}
// SessionIDFromOutput извлекает session_id из текстового вывода opencode.
func SessionIDFromOutput(out string) (string, bool) {
m := sessionRe.FindStringSubmatch(out)
if len(m) > 1 && m[1] != "" {
return m[1], true
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
func stripFence(s string) string {
s = strings.TrimSpace(s)
if f := fenceRe.FindStringSubmatch(s); len(f) > 1 {
s = strings.TrimSpace(f[1])
}
return "", false
return strings.TrimSpace(s)
}

View File

@@ -104,12 +104,3 @@ func TestExtractJSON(t *testing.T) {
})
}
}
func TestSessionIDFromOutput(t *testing.T) {
if s, ok := SessionIDFromOutput(`{"session_id":"abc123"}`); !ok || s != "abc123" {
t.Fatalf("got %q %v", s, ok)
}
if _, ok := SessionIDFromOutput("no session here"); ok {
t.Fatal("expected no match")
}
}

View File

@@ -2,36 +2,11 @@ package opencode
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
)
// parseLiveStep пытается распарсить одну NDJSON-строку stdout opencode как
// событие (text/tool/agent). Возвращает nil, если строка не является событием.
func parseLiveStep(line string) *LiveStep {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "{") {
return nil
}
var obj struct {
Type string `json:"type"`
Part struct {
Text string `json:"text"`
} `json:"part"`
}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
return nil
}
if obj.Type == "" {
return nil
}
return &LiveStep{Type: obj.Type, Text: obj.Part.Text, At: time.Now()}
}
// LiveStep — один наблюдаемый шаг агента из NDJSON-потока opencode run.
// Собирается из live-строк stdout, не из БД.
// LiveStep — один наблюдаемый шаг агента.
type LiveStep struct {
Type string // "text" | "tool" | "agent" | ...
Text string // содержимое text-парта (для других типов может быть пустым)

View File

@@ -1,23 +0,0 @@
//go:build linux
package opencode
import (
"os/exec"
"syscall"
)
func sysProcAttr(proc *exec.Cmd) {
proc.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// killProcGroup убивает всю process-group по лидеру pid (SIGKILL дочерним и
// SIGTERM лидеру). Игнорирует ошибки: weakest-effort teardown.
func killProcGroup(pid int) {
pgid, err := syscall.Getpgid(pid)
if err != nil {
return
}
_ = syscall.Kill(-pgid, syscall.SIGKILL)
_ = syscall.Kill(pid, syscall.SIGKILL)
}

View File

@@ -1,9 +0,0 @@
//go:build !linux
package opencode
import "os/exec"
func sysProcAttr(_ *exec.Cmd) {}
func killProcGroup(pid int) {}

234
internal/opencode/pool.go Normal file
View File

@@ -0,0 +1,234 @@
package opencode
import (
"context"
"fmt"
"log"
"net"
"path/filepath"
"sync"
)
// Pool — контроль над пулом opencode serve-процессов (по одному на каталог).
//
// Ленивый: сервер для каталога поднимается при первом запросе (Ensure), кроме
// служебного root-сервера (EnsureRoot), который живёт с момента старта app.
// Завершение задачи снимает поднятые серверы кроме root'a (ReleaseTask).
//
// Каждый Server слушает свой порт (basePort + сдвиг), запускается в своей
// директории → каждая сессия API привязана к правильному project-каталогу.
type Pool struct {
Bin string
Config string
ConfigDir string
DBPath string
Host string
BasePort int
Password string
rootDir string // каталог служебного сервера
root *Server
ctx context.Context // базовый ctx для всех serve; живёт, пока пул активен
cancel context.CancelFunc
mu sync.Mutex
segs map[string]*Server // dir → сервер (root тоже здесь)
used map[int]bool // занятые порты
next int // следующий кандидат порта
}
// NewPool создаёт пул. rootDir помечен как служебный (не снимается ReleaseTask).
func NewPool(rootDir string) *Pool {
return &Pool{
Host: "127.0.0.1",
BasePort: 4096,
rootDir: rootDir,
segs: make(map[string]*Server),
used: make(map[int]bool),
next: 4096,
}
}
// startMonitored поднимает сервер и запускает его Run-перезапуск (reaper).
// Наследует базовый ctx пула: Serve живёт, пока жив пул.
func (p *Pool) startMonitored(ctx context.Context, s *Server) error {
if p.ctx == nil {
sctx, cancel := context.WithCancel(ctx)
p.ctx, p.cancel = sctx, cancel
}
if err := s.Start(p.ctx); err != nil {
return err
}
go s.Run(p.ctx)
return nil
}
// EnsureRoot поднимает служебный сервер в rootDir (идемпотентен).
func (p *Pool) EnsureRoot(ctx context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.root != nil {
return nil
}
s := &Server{
Bin: p.Bin,
Config: p.Config,
ConfigDir: p.ConfigDir,
DBPath: p.DBPath,
Host: p.Host,
Password: p.Password,
Dir: p.rootDir,
}
if err := p.assign(s); err != nil {
return err
}
if err := p.startMonitored(ctx, s); err != nil {
return fmt.Errorf("opencode serve (root): %w", err)
}
p.root = s
p.segs[p.rootDir] = s
log.Printf("opencode: root serve up at %s (dir %s)", s.Addr(), p.rootDir)
return nil
}
// Ensure гарантирует наличие сервера для каталога dir (лениво).
// Возвращает сервер; root-сервер для rootDir возвращается как есть.
func (p *Pool) Ensure(ctx context.Context, dir string) (*Server, error) {
p.mu.Lock()
if s, ok := p.segs[dir]; ok {
p.mu.Unlock()
return s, nil
}
abs := filepath.Clean(dir)
s := &Server{
Bin: p.Bin,
Config: p.Config,
ConfigDir: p.ConfigDir,
DBPath: p.DBPath,
Host: p.Host,
Password: p.Password,
Dir: abs,
}
if err := p.assign(s); err != nil {
p.mu.Unlock()
return nil, err
}
p.segs[abs] = s
p.mu.Unlock()
if err := p.startMonitored(ctx, s); err != nil {
p.mu.Lock()
delete(p.segs, abs)
p.releasePort(s.Port)
p.mu.Unlock()
return nil, fmt.Errorf("opencode serve (%s): %w", abs, err)
}
log.Printf("opencode: serve up at %s (dir %s)", s.Addr(), abs)
return s, nil
}
// RegisterExternal регистрирует внешний (уже запущенный) сервер для каталога
// dir. Полезно, когда serve поднят вне пула (в т.ч. в тестах): Ensure вернёт
// его без spawn'а. url — полный адрес, по которому Runner ходит через API.
func (p *Pool) RegisterExternal(dir, url string) {
p.mu.Lock()
defer p.mu.Unlock()
abs := filepath.Clean(dir)
p.segs[abs] = &Server{URL: url, Host: p.Host, PollInterval: 0}
if p.rootDir != "" && abs == p.rootDir {
p.root = p.segs[abs]
}
}
// ServerFor возвращает сервер для каталога (без поднятия). ok=false если нет.
func (p *Pool) ServerFor(dir string) (*Server, bool) {
p.mu.Lock()
defer p.mu.Unlock()
s, ok := p.segs[filepath.Clean(dir)]
return s, ok
}
// ReleaseTask закрывает все серверы пула, кроме служебного root. Вызывается
// при завершении задачи.
func (p *Pool) ReleaseTask() {
p.mu.Lock()
var toClose []*Server
for dir, s := range p.segs {
if p.root != nil && dir == p.rootDir {
continue // служебный не снимаем
}
toClose = append(toClose, s)
delete(p.segs, dir)
p.releasePort(s.Port)
}
p.mu.Unlock()
for _, s := range toClose {
log.Printf("opencode: closing serve %s (release task)", s.Addr())
s.Close()
}
}
// Close закрывает все серверы пула, включая root. Идемпотентен.
func (p *Pool) Close() {
p.mu.Lock()
toClose := make([]*Server, 0, len(p.segs))
for dir, s := range p.segs {
toClose = append(toClose, s)
delete(p.segs, dir)
p.releasePort(s.Port)
}
p.root = nil
if p.cancel != nil {
p.cancel()
p.cancel = nil
}
p.mu.Unlock()
for _, s := range toClose {
s.Close()
}
}
// assign выделяет свободный порт и проставляет его серверу.
func (p *Pool) assign(s *Server) error {
port, err := p.allocPort()
if err != nil {
return err
}
s.Port = port
return nil
}
// allocPort находит свободный порт начиная с next, коммитит его.
func (p *Pool) allocPort() (int, error) {
for i := 0; i < 100; i++ {
port := p.next
p.next++
if p.used[port] {
continue
}
if !portFree(p.Host, port) {
p.used[port] = true
continue
}
p.used[port] = true
return port, nil
}
return 0, fmt.Errorf("opencode: нет свободных портов в диапазоне")
}
func (p *Pool) releasePort(port int) {
if port != 0 {
delete(p.used, port)
}
}
// portFree проверяет, свободен ли порт (bind probe).
func portFree(host string, port int) bool {
l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return false
}
l.Close()
return true
}

View File

@@ -0,0 +1,75 @@
package opencode
import (
"path/filepath"
"testing"
"time"
)
func TestPool_EnsureRoot_NoSpawn(t *testing.T) {
// без spawn: пул без Bin — EnsureRoot должен упасть (нет бинаря),
// но НЕ упасть на пустой карте. Проверяем, что повтор вызова не паникует.
dir := t.TempDir()
p := NewPool(dir)
// не запускаем — просто проверяем кэш
p.mu.Lock()
p.segs[dir] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Millisecond}
p.mu.Unlock()
s, ok := p.ServerFor(dir)
if !ok || s == nil {
t.Fatal("ServerFor должен найти закэшированный сервер")
}
}
func TestPool_ServerFor_ReleaseTask(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "a")
b := filepath.Join(dir, "b")
p := NewPool(dir)
p.Bin = fakeServeBin(t, dir)
// root не запускаем; добавляем серверы в карту напрямую (как после Ensure).
p.mu.Lock()
p.segs[a] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Nanosecond}
p.segs[b] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Nanosecond}
p.mu.Unlock()
// ReleaseTask: rootDir в карте НЕ занят, значит оба закрываются.
p.ReleaseTask()
if _, ok := p.ServerFor(a); ok {
t.Error("каталог a должен быть снят после ReleaseTask")
}
if _, ok := p.ServerFor(b); ok {
t.Error("каталог b должен быть снят после ReleaseTask")
}
}
func TestPool_AllocPort_Unique(t *testing.T) {
p := NewPool("/tmp/x")
p.Host = "127.0.0.1"
seen := map[int]bool{}
var ports []int
for i := 0; i < 5; i++ {
port, err := p.allocPort()
if err != nil {
t.Fatalf("allocPort err: %v", err)
}
if seen[port] {
t.Fatalf("дубль порта %d", port)
}
seen[port] = true
ports = append(ports, port)
}
// освобождаем и убеждаемся, что порт можно переиспользовать
p.releasePort(ports[0])
if !p.free(ports[0]) {
t.Errorf("порт %d должен быть свободен после releasePort", ports[0])
}
}
func (p *Pool) free(port int) bool {
p.mu.Lock()
defer p.mu.Unlock()
return !p.used[port]
}

View File

@@ -0,0 +1,24 @@
//go:build linux || darwin || freebsd || netbsd || openbsd || aix || solaris
package opencode
import (
"os/exec"
"syscall"
)
// setpgid выделяет дочернему процессу собственную process-group, чтобы
// killGroup мог убить весь групповой процесс, а не чужой (тест-реннер и т.п.).
func setpgid(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// killGroup шлёт SIGKILL всей process-group процесса (pgid == pid из-за
// Setpgid). Процесс уже завершён — возвращаем 0 и не отвлекаемся на ошибку
// ESRCH (группа могла сама разойтись).
func killGroup(proc *exec.Cmd) {
if proc == nil || proc.Process == nil {
return
}
_ = syscall.Kill(-proc.Process.Pid, syscall.SIGKILL)
}

View File

@@ -0,0 +1,20 @@
//go:build windows
package opencode
import "os/exec"
// На Windows нет POSIX process-group (нет Setpgid / SIGKILL по -pgid).
// setpgid — no-op: дочерние процессы не группируются в отдельную группу.
func setpgid(cmd *exec.Cmd) {}
// killGroup на Windows может убить только сам процесс (Process.Kill),
// дочерние процессы группы не завершаются. Для serve это приемлемо: при
// остановке сервера дочерние подпроцессы opencode всё равно умирают вместе
// с родителем/консолью.
func killGroup(proc *exec.Cmd) {
if proc == nil || proc.Process == nil {
return
}
_ = proc.Process.Kill()
}

View File

@@ -1,48 +1,44 @@
package opencode
import (
"bufio"
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
)
// Result — результат запуска opencode run. rc=-1 означает «убит по таймауту»
// (idle/hard): вызывающий НЕ должен ронять задачу, а обязан закоммитить/запушить
// готовую работу и отправить на ревью (класс O2 Timeout — результат, не ошибка).
// Result — результат запуска opencode-субагента через HTTP API. rc=-1 означает
// «оборван по таймауту/контексту» (idle/hard): вызывающий НЕ должен ронять
// задачу, а обязан закоммитить/запушить готовую работу и отправить на ревью
// (класс O2 Timeout — результат, не ошибка).
type Result struct {
RC int
Stdout string
SessionID string
}
// Runner — конфигурация запуска opencode-субагентов.
// Runner — запуск opencode-субагентов через v2 HTTP API serve.
//
// Runner ходит к opencode serve через Pool→Client (пути /api/*, см. README,
// минимальная версия opencode). Промпт отправляется неблокирующе (durable
// admit), вердикт собирается поллингом новых assistant-сообщений; завершение
// ответа определяется по схеме «сессия больше не в активных дренажах» + финальное
// assistant-сообщение.
type Runner struct {
Bin string // путь к opencode (по умолчанию "opencode")
DBPath string // путь к opencode.db (idle-детекция активности)
Config string // путь к opencode.json (OPENCODE_CONFIG)
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
HardTimeout time.Duration // общий лимит на запуск
Pool *Pool // пул serve-серверов (обязательный)
IdleTimeout time.Duration
HardTimeout time.Duration
PollInterval time.Duration
Debug bool // отладочные логи API-вызовов (из log.level=debug)
// Заменяемые для тестов:
// Заменяемый для тестов:
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
}
func (r *Runner) defaults() {
if r.Bin == "" {
r.Bin = "opencode"
}
if r.IdleTimeout == 0 {
r.IdleTimeout = 5 * time.Minute
}
@@ -61,232 +57,170 @@ func (r *Runner) logf(format string, args ...any) {
fmt.Fprintf(r.Stdout, format+"\n", args...)
}
// maxDirMsgTS — максимальный time_updated (мс) по всем сообщениям сессий этого
// worktree: сигнал «модель/субагенты ещё активны». nil-nil если БД нет/пуста.
func (r *Runner) maxDirMsgTS(ctx context.Context, worktree string) (int64, bool) {
if r.DBPath == "" {
return 0, false
}
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
if err != nil {
return 0, false
}
defer db.Close()
var ts sql.NullInt64
err = db.QueryRowContext(ctx,
"SELECT MAX(m.time_updated) FROM message m JOIN session s ON s.id = m.session_id WHERE s.directory = ?",
worktree).Scan(&ts)
if err != nil || !ts.Valid {
return 0, false
}
return ts.Int64, true
}
func (r *Runner) latestSession(ctx context.Context, worktree, agent string) (string, bool) {
if r.DBPath == "" {
return "", false
}
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
if err != nil {
return "", false
}
defer db.Close()
q := "SELECT id FROM session WHERE directory = ?"
args := []any{worktree}
if agent != "" {
q += " AND agent = ?"
args = append(args, agent)
}
q += " ORDER BY time_created DESC LIMIT 1"
var id string
if err := db.QueryRowContext(ctx, q, args...).Scan(&id); err != nil {
return "", false
}
return id, id != ""
}
// Run запускает opencode run. Возвращает *Result (rc, stdout, session_id).
// Ошибка — только класс O1 ErrSpawn (не смог запустить бинарь). Таймауты
// дают rc=-1 в Result, а не error (класс O2).
// Run запускает opencode-субагента через HTTP API: создаёт/продолжает сессию
// в сервере пула для каталога cwd, отправляет промпт, ждёт вердикт.
//
// Возвращает *Result (rc, stdout=вердикт, session_id). Ошибка — только класс
// O1 ErrRun (не смог обратиться к серверу/сессии). Таймауты дают rc=-1 в
// Result, а не error (класс O2).
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
r.defaults()
cmd := []string{r.Bin, "run", "--agent", agent, "--format", "json", "--dir", cwd}
if sessionID != "" {
cmd = append(cmd, "--session", sessionID)
}
cmd = append(cmd, prompt)
env := append(os.Environ(),
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DISABLE_MODELS_FETCH=1")
if r.Config != "" {
env = append(env, "OPENCODE_CONFIG="+r.Config)
}
if r.ConfigDir != "" {
env = append(env, "OPENCODE_CONFIG_DIR="+r.ConfigDir)
if r.Pool == nil {
return nil, fmt.Errorf("opencode: Pool не задан (API-режим обязателен)")
}
proc := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
proc.Env = env
proc.Dir = cwd
// Убиваем всю process-group, чтобы дочерние процессы (sleep и т.п.) тоже
// умерли и закрыли унаследованные stdout-fd (иначе <-done виснет).
setpgid(proc)
stdout, err := proc.StdoutPipe()
srv, err := r.Pool.Ensure(ctx, cwd)
if err != nil {
return nil, fmt.Errorf("opencode: stdout pipe: %w", err)
return nil, err
}
proc.Stderr = proc.Stdout
if err := proc.Start(); err != nil {
return nil, fmt.Errorf("opencode: start %v: %w", cmd[0], err)
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug}
// Модель по умолчанию из конфига opencode — хардпиним её в сессии, чтобы
// не зависеть от fallback-логики opencode (класс O5 WARN: если модель не
// считывается/не задана — предупреждаем и работаем без явного указания).
model, mErr := ReadModelRef(srv.Config, srv.ConfigDir)
if mErr != nil {
r.logf("WARN opencode: не удалось прочитать model из конфига: %v", mErr)
} else if model == nil {
r.logf("WARN opencode: в конфиге opencode не задан top-level model — модель не хардпинится (риск fallback)")
} else {
r.logf("opencode(%s) model=%s", agent, model)
}
var buf []string
var mu sync.Mutex
done := make(chan struct{})
// liveSeq — кол-во распознанных live-строк (text/tool/agent/reasoning) в
// NDJSON-потоке. Инкрементится из goroutine чтения; поллинг сравнивает,
// чтобы сбросить idle-таймер «пока LLM стримит» (а не только по БД).
var liveSeq atomic.Uint64
prevLive := liveSeq.Load()
// Живое наблюдение сессии (если задано через WithLive в контексте).
liveReg, liveTask := liveFromContext(ctx)
if liveReg != nil && liveTask != 0 {
liveReg.Start(liveTask, agent)
defer liveReg.Finish(liveTask)
// Сессия: заданная (resume) или новая.
sid := sessionID
if sid == "" {
sid, err = c.CreateSession(ctx, model)
if err != nil {
return nil, fmt.Errorf("opencode: create session: %w", err)
}
r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr())
}
go func() {
defer close(done)
sc := bufio.NewScanner(stdout)
// NDJSON opencode пишет каждый объект одной строкой; большой text-парт с
// вердиктом легко превышает дефолтный лимит Scanner в 64КБ → ErrTooLong и
// потеря всего потока после первой строки. Поднимаем до 64МБ.
sc.Buffer(make([]byte, 64*1024), 64*1024*1024)
for sc.Scan() {
line := sc.Text()
mu.Lock()
buf = append(buf, line)
mu.Unlock()
if st := parseLiveStep(line); st != nil {
// «пульс» LLM: что-то стримится/вызывается — сбрасываем idle
liveSeq.Add(1)
if liveReg != nil {
liveReg.Observe(liveTask, *st)
}
}
}
scanErr := sc.Err()
if scanErr != nil {
r.logf("opencode(%s) scan err: %v", agent, scanErr)
}
}()
baseline, _ := r.maxDirMsgTS(ctx, cwd)
return r.awaitVerdict(ctx, c, model, sid, agent, prompt)
}
// settlePolls — сколько подряд опросов должно подтвердить завершение ответа,
// прежде чем считать вердикт финальным (устойчивость к гонке между удалением
// сессии из активных дренажей и финализацией последнего сообщения).
const settlePolls = 2
// awaitVerdict отправляет промпт (неблокирующе) и поллит новые assistant-сообщения,
// контролируя idle/hard таймауты. Завершение: сессия ушла из активных дренажей
// И есть новое завершённое assistant-сообщение, стабильное в течение settlePolls
// опросов. Возвращает вердикт (текст text-партов), либо rc=-1 при таймауте.
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, sid, agent, prompt string) (*Result, error) {
// admit промпта; граница «новых» сообщений — время создания user-сообщения.
admittedAt := time.Now().UnixMilli()
adm, err := c.Prompt(ctx, sid, prompt)
if err != nil {
return nil, err
}
if adm != nil && adm.TimeCreated > 0 {
admittedAt = adm.TimeCreated
}
// Прогресс = число контент-партов + суммарная длина их текста в новых
// assistant-сообщениях (progressOf). Рост сбрасывает idle-таймер: LLM
// стримит (даже в один растущий text-парт) или думает (reasoning) = жив.
lastParts, lastTextLen := -1, -1
lastProgress := time.Now()
launch := time.Now()
killed := false
pollLoop:
for {
select {
case <-done:
// процесс завершился (pipe EOF) — выходим, берём exit code
break pollLoop
case <-ctx.Done():
killGroup(proc)
killed = true
break pollLoop
default:
doneSeen, emptySeen := 0, 0
abortAnd := func(rc int, why string) (*Result, error) {
if err := c.Interrupt(ctx, sid); err != nil {
r.logf("opencode(%s) interrupt %s: %v", agent, why, err)
}
if proc.ProcessState != nil && proc.ProcessState.Exited() {
break pollLoop
return &Result{RC: rc, Stdout: "", SessionID: sid}, nil
}
for {
if ctx.Err() != nil {
r.logf("opencode(%s) ctx cancelled — обрыв (rc=-1)", agent)
return abortAnd(-1, "ctx")
}
msgs, err := c.Messages(ctx, sid)
if err != nil {
if ctx.Err() != nil {
return abortAnd(-1, "ctx")
}
var ce *ClientErr
if errors.As(err, &ce) && ce.Op == "connect" {
return nil, fmt.Errorf("opencode: %w", err)
}
return nil, err
}
active, err := c.Active(ctx, sid)
if err != nil {
if ctx.Err() != nil {
return abortAnd(-1, "ctx")
}
var ce *ClientErr
if errors.As(err, &ce) && ce.Op == "connect" {
return nil, fmt.Errorf("opencode: %w", err)
}
return nil, err
}
cur, _ := newestAssistant(msgs, admittedAt)
parts, textLen := progressOf(msgs, admittedAt)
if parts != lastParts || textLen != lastTextLen {
lastProgress = time.Now()
lastParts, lastTextLen = parts, textLen
}
now := time.Now()
// «Пульс» LLM: если с прошлого поллинга появились live-строки
// (text/tool/agent/reasoning) — LLM реально работает, сбрасываем idle.
if cur := liveSeq.Load(); cur != prevLive {
prevLive = cur
lastProgress = now
}
ts, ok := r.maxDirMsgTS(ctx, cwd)
if ok && ts > baseline {
lastProgress = now
}
if now.Sub(lastProgress) > r.IdleTimeout {
r.logf("opencode(%s) idle %.0fs (нет новых сообщений) — kill", agent, r.IdleTimeout.Seconds())
killGroup(proc)
killed = true
break pollLoop
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
return abortAnd(-1, "idle")
}
if now.Sub(launch) > r.HardTimeout {
r.logf("opencode(%s) hard timeout %.0fs — kill", agent, r.HardTimeout.Seconds())
killGroup(proc)
killed = true
break pollLoop
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
return abortAnd(-1, "hard")
}
time.Sleep(r.PollInterval)
}
<-done
procErr := proc.Wait()
rc := proc.ProcessState.ExitCode()
if rc < 0 {
rc = 1
}
if killed {
rc = -1
}
_ = procErr
switch {
case !active && cur != nil && cur.finished():
// ответ закончен — ждём стабильности, затем собираем вердикт
doneSeen++
emptySeen = 0
if doneSeen >= settlePolls {
return r.verdict(model, cur, msgs, admittedAt, sid)
}
case !active && cur == nil:
// сессия завершилась, но нового assistant-сообщения так и нет
emptySeen++
if emptySeen >= settlePolls {
return nil, &ClientErr{Op: "prompt", Err: errors.New("агент не выдал ответ (сессия пуста)")}
}
default:
doneSeen, emptySeen = 0, 0
}
mu.Lock()
out := strings.Join(buf, "\n")
mu.Unlock()
r.logf("opencode(%s) lines=%d bytes=%d", agent, len(buf), len(out))
sid := sessionID
if s, ok := SessionIDFromOutput(out); ok {
sid = s
}
if rc == -1 && sid == "" {
if s, ok := r.latestSession(ctx, cwd, agent); ok {
sid = s
select {
case <-time.After(r.PollInterval):
case <-ctx.Done():
}
}
r.logf("opencode(%s) rc=%d", agent, rc)
return &Result{RC: rc, Stdout: out, SessionID: sid}, nil
}
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
// падает с rc!=0 (напр. сессия потеряна) — повторяем ОДИН раз свежей сессией
// в том же worktree. rc=-1 (kill по таймауту) НЕ триггерит fallback.
// Возвращает (result, timedOut).
func (r *Runner) ResumeDev(ctx context.Context, prompt, cwd, sessionID string) (*Result, bool) {
res, err := r.Run(ctx, prompt, cwd, "dev", sessionID)
if err != nil {
// spawn-ошибку не ретраим fallback'ом — она повторится
return res, false
// verdict собирает финальный результат из новых assistant-сообщений.
// Проверяет фактическую модель ответа и логирует warning при расхождении
// с ожидаемой (устойчивость к «не той» модели — класс O5 WARN).
func (r *Runner) verdict(model *ModelRef, cur *v2Message, msgs []v2Message, since int64, sid string) (*Result, error) {
if model != nil && cur.Model != nil && (model.ProviderID != cur.Model.ProviderID || model.ID != cur.Model.ID) {
r.logf("WARN opencode: сессия %s отвечала моделью %s, а не ожидаемой %s — проверь providers в конфиге (v2-схема: provider.api / request, а не npm/options)", sid, cur.Model, model)
}
if res.RC != 0 && res.RC != -1 && sessionID != "" {
r.logf("dev resume rc=%d — запускаю заново без --session (worktree сохраняю)", res.RC)
res, _ = r.Run(ctx, prompt+resumeFallbackNote, cwd, "dev", "")
if cur.Error != nil && cur.Error.Message != "" {
return nil, &ClientErr{Op: "prompt", Err: errors.New(cur.Error.Message)}
}
return res, res.RC == -1
}
const resumeFallbackNote = "\n\n(Возобновление сессии не удалось; продолжи с учётом уже сделанных изменений в worktree.)"
// --- process-group helpers (Linux) ---
// Ставим процесс в собственную process-group, чтобы killGroup мог убить и
// дочерние процессы (иначе они держат унаследованные stdout-fd и <-done виснет).
func setpgid(proc *exec.Cmd) {
sysProcAttr(proc)
}
func killGroup(proc *exec.Cmd) {
if proc.Process != nil {
killProcGroup(proc.Process.Pid)
texts := assistantText(msgs, since)
if len(texts) == 0 {
return nil, &ClientErr{Op: "prompt", Err: errors.New("нет text-части в ответе")}
}
_ = proc.Process.Kill()
vd := stripFence(strings.Join(texts, "\n"))
r.logf("opencode вердикт готов (%d байт)", len(vd))
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
}

View File

@@ -2,60 +2,41 @@ package opencode
import (
"context"
"os"
"path/filepath"
"io"
"net/http/httptest"
"testing"
"time"
)
// fakeOpenCode создаёт shell-скрипт, имитирующий opencode run:
//
// $FAKE_MODE=ok -> мгновенный успех, печатает NDJSON c session_id
// $FAKE_MODE=slow-> спит долго (для idle/hard timeout)
// $FAKE_MODE=fail-> exit 7 (resume-fallback)
func fakeOpenCode(t *testing.T, workdir string) string {
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
// XDG_CONFIG_HOME уводится во временный каталог, чтобы ReadModelRef не читал
// реальный пользовательский конфиг opencode (детерминизм тестов).
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
t.Helper()
bin := filepath.Join(workdir, "opencode")
script := `#!/bin/sh
mode="${FAKE_MODE:-ok}"
case "$mode" in
ok)
echo '{"type":"text","part":{"text":"done"}}'
echo '{"session_id":"sess-123"}'
exit 0
;;
slow)
sleep 30
;;
live-reset)
# шлём live-строку каждые 30мс долго — почти до hard timeout,
# чтобы idle-таймер (50мс) НЕ убил из-за стрима
i=0
while [ $i -lt 20 ]; do
echo '{"type":"text","part":{"text":"tick"}}'
sleep 0.03
i=$((i+1))
done
sleep 30
;;
fail)
echo '{"type":"text","part":{"text":"boom"}}'
exit 7
;;
esac
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write fake opencode: %v", err)
}
return bin
ts := httptestURL(t, f)
p := NewPool(dir)
p.mu.Lock()
p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond}
p.mu.Unlock()
return p, &Client{BaseURL: ts}
}
// httptestURL запускает фейк-API и возвращает его URL.
func httptestURL(t *testing.T, f *fakeAPIServer) string {
t.Helper()
ts := httptest.NewServer(f.handler())
t.Cleanup(ts.Close)
return ts.URL
}
func TestRun_Success(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "ok")
f := &fakeAPIServer{verdictText: "done"}
p, _ := fakePool(t, f, dir)
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
res, err := r.Run(context.Background(), "task", dir, "dev", "")
if err != nil {
t.Fatalf("Run err: %v", err)
@@ -63,86 +44,81 @@ func TestRun_Success(t *testing.T) {
if res.RC != 0 {
t.Errorf("RC = %d, want 0", res.RC)
}
if res.SessionID != "sess-123" {
t.Errorf("SessionID = %q, want sess-123", res.SessionID)
if res.SessionID != "sess-fake" {
t.Errorf("SessionID = %q, want sess-fake", res.SessionID)
}
if !contains(res.Stdout, "done") {
t.Errorf("Stdout = %q, want to contain done", res.Stdout)
}
}
func TestRun_LiveRegistry(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "ok")
reg := NewLiveRegistry()
ctx := WithLive(context.Background(), reg, 42)
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
res, err := r.Run(ctx, "task", dir, "dev", "")
if err != nil {
t.Fatalf("Run err: %v", err)
}
if res.RC != 0 {
t.Fatalf("RC = %d, want 0", res.RC)
}
// После завершения Finish удаляет сессию → Snap не найден.
if _, ok := reg.Snap(42); ok {
t.Error("сессия не удалена после Finish (должна быть, т.к. задача завершилась)")
t.Errorf("Stdout = %q, want contain done", res.Stdout)
}
}
func TestRun_IdleTimeout(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "slow")
// агент «завис»: active=true, прогресс не растёт → idle abort
f := &fakeAPIServer{blockPrompt: true}
p, _ := fakePool(t, f, dir)
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
PollInterval: 10 * time.Millisecond}
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
res, err := r.Run(context.Background(), "task", dir, "dev", "")
if err != nil {
t.Fatalf("Run err: %v", err)
}
if res.RC != -1 {
t.Errorf("RC = %d, want -1 (timeout kill)", res.RC)
t.Errorf("RC = %d, want -1 (idle timeout)", res.RC)
}
}
// TestRun_LiveResetsIdle: пока LLM стримит live-строки, idle-таймер должен
// сбрасываться, а не убивать процесс по истечении короткого IdleTimeout.
func TestRun_LiveResetsIdle(t *testing.T) {
func TestRun_StreamingGrowth(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "live-reset")
// стрим: один text-парт растёт с каждым опросом (дольше, чем idle timeout),
// но модель жива → idle НЕ должен сработать.
f := &fakeAPIServer{streamGrow: true, streamPolls: 30}
p, _ := fakePool(t, f, dir)
// idle очень короткий (50мс), hard большой (3с). live-reset стримит ~0.6с.
// Если live-строки НЕ сбрасывают idle — процесс убьют на ~50мс, и Run
// вернётся быстрее. Если сбрасывают — Run живёт ≥ стрима (~0.6с) до hard.
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
HardTimeout: 3 * time.Second, PollInterval: 10 * time.Millisecond}
start := time.Now()
r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond,
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
res, err := r.Run(context.Background(), "task", dir, "dev", "")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Run err: %v", err)
}
if res.RC != -1 {
t.Errorf("RC = %d, want -1 (killed по hard timeout)", res.RC)
if res.RC != 0 {
t.Errorf("RC = %d, want 0 (растущий стрим не должен считаться hung)", res.RC)
}
if elapsed < 400*time.Millisecond {
t.Errorf("Run вернулся за %v — idle убил во время стрима (live не сбросил таймер)", elapsed)
if !contains(res.Stdout, "done-stream") {
t.Errorf("Stdout = %q, want contain done-stream", res.Stdout)
}
}
func TestRun_ReasoningGrowth(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
// стрим: растёт только reasoning-парт (текста нет) — тоже живая активность.
f := &fakeAPIServer{streamGrow: true, streamReasoning: true, streamPolls: 30}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond,
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
res, err := r.Run(context.Background(), "task", dir, "dev", "")
if err != nil {
t.Fatalf("Run err: %v", err)
}
if res.RC != 0 {
t.Errorf("RC = %d, want 0 (растущий reasoning не должен считаться hung)", res.RC)
}
}
func TestRun_ContextCancel(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "slow")
f := &fakeAPIServer{blockPrompt: true}
p, _ := fakePool(t, f, dir)
ctx, cancel := context.WithCancel(context.Background())
r := &Runner{Bin: bin, HardTimeout: time.Minute,
PollInterval: 10 * time.Millisecond}
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
done := make(chan *Result, 1)
errCh := make(chan error, 1)
go func() {
@@ -161,22 +137,6 @@ func TestRun_ContextCancel(t *testing.T) {
}
}
func TestResumeDev_Fallback(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "fail")
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session")
if timedOut {
t.Error("timedOut = true, want false")
}
// fake fail всегда exit 7, fallback тоже 7 — проверяем что RC от fallback-вызова
if res.RC != 7 {
t.Errorf("RC = %d, want 7 (fallback повтор с тем же кодом)", res.RC)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
}
@@ -188,4 +148,4 @@ func indexOf(s, sub string) int {
}
}
return -1
}
}

281
internal/opencode/server.go Normal file
View File

@@ -0,0 +1,281 @@
package opencode
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
)
// Server — супервайзер постоянного opencode serve (режим --attach).
//
// Вариант A интеграции: один headless-сервер живёт долго (тёплые модели и MCP),
// а Runner ходит к нему через `opencode run --attach <url> --dir cwd ...`.
//
// Два режима владения процессом:
// - URL == "": супервайзер сам spawn'ит `opencode serve`, следит через
// /global/health, рестартует при падении, гасит при Close.
// - URL != "": внешний сервер — супервайзер только проверяет доступность и
// отдаёт URL, процессом не владеет.
type Server struct {
Bin string // путь к opencode (по умолчанию "opencode")
Config string // OPENCODE_CONFIG
ConfigDir string // OPENCODE_CONFIG_DIR
DBPath string // рабочая БД сервера (передам env, если задана)
Host string // hostname для прослушивания
Port int // порт сервера
Password string // basic auth (если непустой — сервер защищён)
Dir string // каталог, в котором запускается serve (project сервера)
// URL задаёт внешний сервер. Пусто — супервайзер владеет процессом.
URL string
PollInterval time.Duration // как часто проверять /global/health
// Заменяемые для тестов:
Stdout io.Writer
mu sync.Mutex
proc *exec.Cmd
done chan struct{} // закрывается reaper'ом при выходе процесса
closed bool
}
func (s *Server) defaults() {
if s.Bin == "" {
s.Bin = "opencode"
}
if s.Host == "" {
s.Host = "127.0.0.1"
}
if s.Port == 0 {
s.Port = 4096
}
if s.PollInterval == 0 {
s.PollInterval = 5 * time.Second
}
if s.Stdout == nil {
s.Stdout = os.Stderr
}
}
// baseURL собирает полный адрес сервера (http://host:port).
func (s *Server) baseURL() string {
s.defaults()
return fmt.Sprintf("http://%s:%d", s.Host, s.Port)
}
// Addr возвращает URL, по которому Runner должен ходить через --attach.
func (s *Server) Addr() string {
if s.URL != "" {
return s.URL
}
return s.baseURL()
}
// Start запускает сервер (внешний — просто проверку) в фоне.
// Возвращает ошибку, если процесс не удалось поднять или первый healthcheck
// не прошёл (serve доступен, но ещё «тёплый»).
func (s *Server) Start(ctx context.Context) error {
s.defaults()
if s.URL != "" {
// внешний сервер — не владеем процессом, только ждём доступность
return s.waitHealthy(ctx, s.URL)
}
addr := s.baseURL()
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return fmt.Errorf("opencode serve: server closed")
}
cmd := s.serveCmd(ctx)
cmd.Stdout = s.Stdout
cmd.Stderr = s.Stdout
done := make(chan struct{})
s.proc = cmd
s.done = done
s.mu.Unlock()
if err := cmd.Start(); err != nil {
return fmt.Errorf("opencode serve: start %v: %w", s.Bin, err)
}
// reaper: ждём выход процесса и закрываем done — единственный владелец
// Wait (Close его не трогает, Run поллит ProcessState).
go func() {
_ = cmd.Wait()
close(done)
}()
return s.waitHealthy(ctx, addr)
}
// serveCmd собирает команду запуска сервера.
func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
args := []string{"serve", "--hostname", s.Host, "--port", fmt.Sprintf("%d", s.Port)}
cmd := exec.CommandContext(ctx, s.Bin, args...)
cmd.Dir = s.Dir // project сервера — каталог, который обслуживает этот serve
if cmd.Dir == "" {
cmd.Dir = "."
}
// Своя process-group: чтобы killGroup (по pgid) убивал только сервер и его
// дочерние процессы, а не чужой процесс (например, тест-реннер).
setpgid(cmd)
env := append(os.Environ(),
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DISABLE_MODELS_FETCH=1")
if s.Config != "" {
env = append(env, "OPENCODE_CONFIG="+s.Config)
}
if s.ConfigDir != "" {
env = append(env, "OPENCODE_CONFIG_DIR="+s.ConfigDir)
}
if s.DBPath != "" {
env = append(env, "OPENCODE_DB="+s.DBPath)
}
if s.Password != "" {
env = append(env, "OPENCODE_SERVER_PASSWORD="+s.Password)
}
cmd.Env = env
return cmd
}
// MinVersion — минимальная версия opencode, с которой работает интеграция.
// v2 HTTP API (префикс /api/*) присутствует в сборках dev / >=1.18.18.
// Более старые бинари отвечают на /global/health и НЕ подходят.
const MinVersion = "1.18.18"
// waitHealthy опрашивает /api/health сервера до первого успеха или Connect.
// Возвращает nil, как только сервер ответил {healthy:true} (или 200/401 — сервер
// жив, но может требовать авторизации). При неудаче — ошибка с подсказкой про
// минимальную версию opencode (класс O1: старый бинарь не знает v2-путей).
func (s *Server) waitHealthy(ctx context.Context, addr string) error {
deadline := time.Now().Add(60 * time.Second)
poll := s.PollInterval
for {
if healthy := s.health(ctx, addr); healthy {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("opencode serve %s: не стал доступным (v2 healthcheck). Нужен opencode >= %s (v2 HTTP API /api/*), а не старый бинарь", addr, MinVersion)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// healthGET делает GET на адрес и возвращает true, если сервер ответил.
// 401 (basic auth требуется) тоже считается «жив» — сервер доступен.
func (s *Server) healthGET(ctx context.Context, addr string) bool {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr+"/api/health", nil)
if err != nil {
return false
}
if s.Password != "" {
req.SetBasicAuth("opencode", s.Password)
}
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
// 200/403/401 — сервер жив (остальное считаем недоступным)
return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden
}
// Run следит за сервером, пока ctx не отменён.
//
// В режиме владения (URL=="") перезапускает owned-процесс, если тот вышел
// (cmd.ProcessState указывает на завершение). Внешний сервер (URL!="") просто
// поллится на доступность и логирует сбои — процессом не владеем.
func (s *Server) Run(ctx context.Context) {
s.defaults()
addr := s.Addr()
for {
select {
case <-ctx.Done():
s.Close()
return
case <-time.After(s.PollInterval):
}
s.mu.Lock()
proc := s.proc
closed := s.closed
s.mu.Unlock()
if closed {
return
}
if s.URL != "" {
// внешний сервер упал — не наша работа перезапускать, но логируем
if !s.health(ctx, addr) {
log.Printf("opencode serve: внешний сервер %s недоступен", addr)
}
continue
}
// наш процесс: перезапускаем, если он вышел. Признак выхода —
// cmd.ProcessState != nil: его выставляет reaper-горутина (cmd.Wait)
// только когда процесс завершился любым способом (exit, сигнал, OOM).
// .Exited() использовать нельзя — для SIGKILL он false.
exited := proc == nil || proc.ProcessState != nil
if !exited {
continue
}
log.Printf("opencode serve: процесс упал — перезапускаю")
s.mu.Lock()
cmd := s.serveCmd(ctx)
cmd.Stdout = s.Stdout
cmd.Stderr = s.Stdout
done := make(chan struct{})
s.proc = cmd
s.done = done
s.mu.Unlock()
if err := cmd.Start(); err != nil {
log.Printf("opencode serve: перезапуск не удался: %v", err)
continue
}
go func() {
_ = cmd.Wait()
close(done)
}()
}
}
// health оборачивает healthGET, игнорируя нерелевантные ошибки.
func (s *Server) health(ctx context.Context, addr string) bool {
hctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return s.healthGET(hctx, addr)
}
// Close гасит процесс, которым владеет супервайзер. Идемпотентен.
// Wait НЕ вызываем — reaper-горутина единственный владелец Wait; Close лишь
// убивает процесс и ждёт, когда reaper закроет канал done.
func (s *Server) Close() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
proc := s.proc
done := s.done
s.mu.Unlock()
if proc != nil && proc.Process != nil {
killGroup(proc)
}
if done != nil {
select {
case <-done:
case <-time.After(5 * time.Second):
}
}
}

View File

@@ -0,0 +1,201 @@
package opencode
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// fakeServeBin создаёт скрипт, имитирующий opencode serve: просто держит
// процесс живым (sleep/ping), чтобы супервайзер мог им владеть и убивать его.
// На Windows используется .cmd (с #!/bin/sh нельзя — он не исполняется).
func fakeServeBin(t *testing.T, workdir string) string {
t.Helper()
if runtime.GOOS == "windows" {
bin := filepath.Join(workdir, "opencode-serve.cmd")
script := "@echo off\r\necho fake serve started\r\nping -n 300 127.0.0.1 >nul\r\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write fake serve bin: %v", err)
}
return bin
}
bin := filepath.Join(workdir, "opencode-serve")
script := `#!/bin/sh
echo "fake serve started"
sleep 300
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write fake serve bin: %v", err)
}
return bin
}
// healthHandler — http.Health, отвечающий на GET /global/health 200.
func healthHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}
func TestServer_ExternalURL(t *testing.T) {
// внешний сервер — ходим на реальный httptest-адрес, процессом не владеем
ts := httptest.NewServer(healthHandler())
defer ts.Close()
s := &Server{
URL: ts.URL,
PollInterval: 20 * time.Millisecond,
Stdout: io.Discard,
}
if err := s.Start(context.Background()); err != nil {
t.Fatalf("Start(внешний) err: %v", err)
}
if got := s.Addr(); got != ts.URL {
t.Errorf("Addr() = %q, want %q", got, ts.URL)
}
// Close в режиме внешнего — не должен ничего падать (proc==nil)
s.Close()
}
func TestServer_OwnProcess_StartAHealthyClose(t *testing.T) {
dir := t.TempDir()
bin := fakeServeBin(t, dir)
// поднимаем реальный health-сервер на известном порту, чтобы superватизору
// было на что отвечать /global/health
ts := httptest.NewServer(healthHandler())
defer ts.Close()
host := strings.TrimPrefix(ts.URL, "http://") // host:port
var hostname, port string
if i := strings.IndexByte(host, ':'); i >= 0 {
hostname, port = host[:i], host[i+1:]
} else {
hostname = host
port = "80"
}
s := &Server{
Bin: bin,
Host: hostname,
Port: 0, // сюда передадим порт ниже
PollInterval: 20 * time.Millisecond,
Stdout: io.Discard,
}
// переопределение порта на порт health-сервера
sport := atoiOrZero(port)
s.Port = sport
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := s.Start(ctx); err != nil {
t.Fatalf("Start(владеющий) err: %v", err)
}
if s.proc == nil || s.proc.Process == nil {
t.Fatal("proc не запущен после Start")
}
// Close должен убить процесс
s.Close()
if s.proc.ProcessState == nil {
t.Log("процесс ещё числится запущенным (Close в Go не всегда виден сразу) — ок")
}
}
// TestServer_OwningProcess_Restart проверяет, что Run перезапускает упавший
// процесс: после первого старта убиваем вручную, Run должен поднять вновь.
func TestServer_OwningProcess_Restart(t *testing.T) {
dir := t.TempDir()
bin := fakeServeBin(t, dir)
ts := httptest.NewServer(healthHandler())
defer ts.Close()
host := strings.TrimPrefix(ts.URL, "http://")
hostname, port := host, "80"
if i := strings.IndexByte(host, ':'); i >= 0 {
hostname, port = host[:i], host[i+1:]
}
s := &Server{
Bin: bin,
Host: hostname,
Port: atoiOrZero(port),
PollInterval: 30 * time.Millisecond,
Stdout: io.Discard,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := s.Start(ctx); err != nil {
t.Fatalf("Start err: %v", err)
}
// убиваем первый процесс, чтобы спровоцировать рестарт. Wait НЕ вызываем
// сами — reaper-горутина (Start) владеет реaper'ом и установит
// cmd.ProcessState; ждём, когда статус покажет выход.
first := s.proc
if first == nil {
t.Fatal("proc nil после Start")
}
_ = first.Process.Kill()
waitExited(t, first)
// Run крутится в фон: даём время на рестарт
done := make(chan struct{})
go func() {
s.Run(ctx)
close(done)
}()
defer func() {
cancel()
<-done
s.Close()
}()
// ждём, пока proc появится вновь (Run пересоздаст serveCmd)
deadline := time.Now().Add(3 * time.Second)
var restarted bool
for time.Now().Before(deadline) {
s.mu.Lock()
p := s.proc
s.mu.Unlock()
if p != nil && p != first && p.Process != nil {
restarted = true
break
}
time.Sleep(20 * time.Millisecond)
}
if !restarted {
t.Fatal("процесс не был перезапущен после падения")
}
}
// waitExited ждёт, когда reaper-горутина (cmd.Wait) отметит выход процесса.
func waitExited(t *testing.T, cmd *exec.Cmd) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cmd.ProcessState != nil {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("процесс так и не встал в exited после Kill")
}
func atoiOrZero(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
break
}
n = n*10 + int(c-'0')
}
return n
}

View File

@@ -1,66 +1,43 @@
package storage
import "encoding/json"
import (
"encoding/json"
// Status — статус задачи (state machine).
type Status string
"github.com/kamelion/ratatoskr-go/internal/model"
)
// Status — алиас доменного статуса задачи.
//
// Совместимый мост: весь внешний код продолжает использовать storage.Status
// (например «storage.StatusRunning»), но единый источник истины — model.Status.
type Status = model.Status
// Статусы задачи — re-export из model.
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" // закрыта вручную
StatusDraft = model.StatusDraft
StatusCollecting = model.StatusCollecting
StatusReady = model.StatusReady
StatusApproved = model.StatusApproved
StatusRunning = model.StatusRunning
StatusSuccess = model.StatusSuccess
StatusFailed = model.StatusFailed
StatusTimeout = model.StatusTimeout
StatusCancelled = model.StatusCancelled
StatusAborted = model.StatusAborted
StatusClosed = model.StatusClosed
)
// 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: {}, // терминальный
}
var AllStatuses = model.AllStatuses
// 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
return model.IsValidTransition(from, to)
}
// IsTerminal возвращает true, если статус терминальный.
func IsTerminal(s Status) bool {
return s == StatusSuccess || s == StatusCancelled ||
s == StatusAborted || s == StatusClosed
return model.IsTerminal(s)
}
// Task — запись задачи в БД.
@@ -111,14 +88,14 @@ func (t *Task) SetReposFromDB(repos string) {
_ = json.Unmarshal([]byte(repos), &t.Repos)
}
// Trace — запись трассировки выполнения.
type TraceStatus string
// TraceStatus — алиас доменного статуса трассировки.
type TraceStatus = model.TraceStatus
const (
TraceRunning TraceStatus = "running"
TraceSuccess TraceStatus = "success"
TraceFailed TraceStatus = "failed"
TraceTimeout TraceStatus = "timeout"
TraceRunning TraceStatus = model.TraceRunning
TraceSuccess TraceStatus = model.TraceSuccess
TraceFailed TraceStatus = model.TraceFailed
TraceTimeout TraceStatus = model.TraceTimeout
)
// 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, p.input, nil)
}
// 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,49 @@
//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"
)
// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных логов.
//
// Внутри — widget.Label с ограниченным буфером (maxLogLen); Append обрезает
// строку до лимита. Вызовы Append приходят из горутин Hub, поэтому обновление
// виджета выполняется через fyne.Do (на потоке Fyne).
type LogPanel struct {
label *widget.Label
}
// maxLogLen — обрезка буфера панели «Логи», чтобы не расти бесконечно.
const maxLogLen = 200_000
// NewLogPanel создаёт LogPanel.
func NewLogPanel() *LogPanel {
l := widget.NewLabel("")
l.Wrapping = fyne.TextWrapWord
return &LogPanel{label: l}
}
// Widget возвращает контейнер панели для встраивания в окно (таб «Логи»).
func (p *LogPanel) Widget() fyne.CanvasObject {
return container.NewScroll(p.label)
}
// Append добавляет строку лога (спец 12.9), с ограничением буфера.
func (p *LogPanel) Append(text string) {
fyne.Do(func() {
s := p.label.Text + text + "\n"
if len(s) > maxLogLen {
s = s[len(s)-maxLogLen:]
}
p.label.SetText(s)
})
}
// compile-time проверка реализации контракта.
var _ ui.LogPanel = (*LogPanel)(nil)

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,331 @@
//go:build cgo
// Package desktop — Fyne-реализация окна UI (см. internal/ui).
//
// Сборка требует cgo (GLFW). На машинах без C-компилятора пакет не входит
// в сборку, и app работает в headless-режиме (newUIWindow → nil).
package desktop
import (
"context"
"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"
splitVPref = "split.v"
)
// Window — Fyne-окно: chat.Channel + ui.View.
type Window struct {
fyneApp fyne.App
win fyne.Window
store ui.Store
commands *ui.Commands
handler chat.Handler
// состояние выбора (живёт в окне, спец 12.10)
selected int64
// виджеты
taskList *TaskListPanel
detail *TaskDetailPanel
chat *ChatPanel
state *StatePanel
logs *LogPanel
hsplit *container.Split
vsplit *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,
}
w.win = fa.NewWindow("Ratatoskr")
w.win.SetFullScreen(true)
w.commands = ui.NewCommands(w.submitText)
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.chat = NewChatPanel()
w.chat.SetCommands(w.commands)
w.state = NewStatePanel()
w.logs = NewLogPanel()
// Список задач (слева)
w.taskList = NewTaskListPanel()
w.taskList.OnSelect(func(id int64) { w.selectTask(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())
// Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане).
details := w.detail.Widget()
right := container.NewVSplit(
container.NewScroll(details),
w.chat.Transcript(),
)
w.vsplit = right
// Нижняя панель: вкладки «Логи» + «Состояние».
bottomTabs := container.NewAppTabs(
container.NewTabItem("Логи", w.logs.Widget()),
container.NewTabItem("Состояние", w.state.Widget()),
)
bottom := container.NewBorder(nil, w.chat.Composer(), nil, nil, bottomTabs)
w.hsplit = container.NewHSplit(left, right)
root := container.NewVSplit(w.hsplit, bottom)
w.win.SetContent(root)
// Восстановление layout из Preferences.
pref := w.fyneApp.Preferences()
w.hsplit.SetOffset(pref.FloatWithFallback(splitHPref, 0.30))
w.vsplit.SetOffset(pref.FloatWithFallback(splitVPref, 0.45))
// Начальный снимок списка.
w.refreshList()
}
// saveLayout сохраняет текущие позиции сплитов в Preferences.
// Вызывается при сворачивании окна и перед полным выходом.
func (w *Window) saveLayout() {
pref := w.fyneApp.Preferences()
pref.SetFloat(splitHPref, w.hsplit.Offset)
pref.SetFloat(splitVPref, w.vsplit.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.chat.Clear()
return
}
w.detail.ShowTask(t)
// Диалог: история + краткие трассы.
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 — начать задачу.")
}
w.chat.SetTranscript(b.String())
// Состояние: сброс к снимку задач.
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(text string) {
w.logs.Append(text)
}
// submitText отправляет ввод пользователя в Router.
func (w *Window) submitText(text string) {
if w.handler != nil {
w.handler(chat.Incoming{
UserID: ui.UID,
Address: ui.Address,
Channel: w,
Msg: chat.Message{Text: text},
})
}
}
// ---- 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()
go func() {
<-ctx.Done()
w.fyneApp.Quit()
}()
w.fyneApp.Run()
return nil
}
// Send доставляет сообщение Core в диалог окна.
func (w *Window) Send(_ context.Context, _ chat.Address, m chat.Message) error {
text := m.Text
if len(m.Options) > 0 {
var sb strings.Builder
sb.WriteString(text)
for i, opt := range m.Options {
sb.WriteString("\n")
sb.WriteString(opt.Label)
_ = i
}
text = sb.String()
}
fyne.Do(func() { w.chat.Append("🤖 " + text) })
return nil
}
// Ask — вопрос с вариантами (отображается в диалоге).
func (w *Window) Ask(_ context.Context, _ chat.Address, m chat.Message) error {
text := m.Text
for _, opt := range m.Options {
text += "\n" + opt.Label
}
fyne.Do(func() { w.chat.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()
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() {
if w.selected == e.TaskID {
w.chat.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.Text)
}

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

@@ -0,0 +1,29 @@
// Package ui — контроллер и представления десктопного интерфейса (Fyne).
//
// Слой обмена с ядром: однонаправленный поток (спец 12.212.5).
// - Core мутирует состояние; UI только читает снимки и реагирует на события.
// - Действия UI = команды (CreateTask/ApproveTask/...), которые зовут Core.
// - Подписчик шины (Controller) получает события и перекладывает их в View
// (реализация Fyne) через fyne.Do — никаких прямых вызовов Fyne из core.
//
// Контракты компонентов-виджетов (спец 12.8): интерфейс + Nil-реализация.
package ui
// LogPanel — панель «Логи» (спец 12.9): поток системных логов (сырые строки).
//
// Содержит строки лога в буфере ограниченного размера; при превышении лимита
// старые строки отбрасываются. Контейнер виджета отдаётся окну для встраивания
// в таб «Логи» — реализация (Fyne) сама владеет своим виджетом.
type LogPanel interface {
// Append добавляет строку лога. Реализация обязана перекладывать работу
// на поток Fyne (fyne.Do / fyne.DoAndWait), т.к. вызывается из горутин Hub.
Append(text string)
}
// NilLogPanel — no-op реализация LogPanel для headless-режима и тестов.
type NilLogPanel struct{}
// NewNilLogPanel создаёт NilLogPanel.
func NewNilLogPanel() *NilLogPanel { return &NilLogPanel{} }
func (NilLogPanel) Append(string) {}

View File

@@ -0,0 +1,12 @@
package ui
import "testing"
// NilLogPanel — no-op контракт (спец 12.8): Append ничего не делает, безопасно
// вызывается из горутин Hub и из тестов без Fyne.
func TestNilLogPanelAppendNoop(t *testing.T) {
p := NewNilLogPanel()
p.Append("line 1")
p.Append("")
p.Append("line 3")
}

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"
"time"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
@@ -56,6 +57,27 @@ type Worker struct {
// подменяемый poll для тестов
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 оборачивает контекст запуска субагента, привязывая живое наблюдение
@@ -192,8 +214,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
}
// 2. ставим running (после валидации — чтобы плохие имена не жгли состояние)
task.Status = storage.StatusRunning
if err := w.Store.UpdateTask(ctx, task); err != nil {
if err := w.setStatus(ctx, task, storage.StatusRunning); err != nil {
return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
}
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 -1:
task.Status = storage.StatusTimeout
if e := w.Store.UpdateTask(ctx, task); e != nil {
if e := w.setStatus(ctx, task, storage.StatusTimeout); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notifyStatus(ctx, task, storage.StatusTimeout)
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
return nil
default:
task.Status = storage.StatusFailed
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)
}
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 {
// невалидный JSON даже после retry → failed с объяснением.
task.Status = storage.StatusFailed
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)
}
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)
return pErr
}
task.Status = storage.StatusSuccess
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notifyStatus(ctx, task, storage.StatusSuccess)
return nil
if e := w.setStatus(ctx, task, storage.StatusSuccess); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notifyStatus(ctx, task, storage.StatusSuccess)
return nil
}
// Не пройдено: если есть итерации — dev дорабатывает.
if iter+1 < maxReviewIterations {
@@ -341,8 +358,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
}
// Лимит исчерпан → failed с объяснением.
task.Status = storage.StatusFailed
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)
}
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 и уведомляет владельца.
func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
task.Status = storage.StatusFailed
if e := w.Store.UpdateTask(ctx, task); e != nil {
if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
log.Printf("worker: task %d: set failed: %v", task.ID, e)
}
w.notifyStatus(ctx, task, storage.StatusFailed)

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -809,13 +808,41 @@ func TestWorkerStartStop(t *testing.T) {
w.Stop()
}
// waitTaskStatus ждёт, пока задача достигнет статуса want. На Windows git-операции
// воркера заметно медленнее, чем на Linux, поэтому проверки в тестах не могут
// полагаться на фиксированные sleep'ы — только на polling до целевого статуса.
func waitTaskStatus(t *testing.T, ctx context.Context, s *storage.Storage, id int64, want storage.Status) {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for {
task, err := s.GetTask(ctx, id)
if err != nil {
t.Fatalf("get task %d: %v", id, err)
}
if task.Status == want {
return
}
switch task.Status {
case storage.StatusFailed, storage.StatusTimeout:
t.Fatalf("task %d: status %q, want %q", id, task.Status, want)
}
if time.Now().After(deadline) {
t.Fatalf("task %d: таймаут ожидания %q, последний статус %q", id, want, task.Status)
}
select {
case <-ctx.Done():
t.Fatalf("task %d: ctx done: %v", id, ctx.Err())
case <-time.After(50 * time.Millisecond):
}
}
}
func TestWorkerSemaphore(t *testing.T) {
s := setupWorkerDB(t)
// создаём 2 ready-задачи
for i := 0; i < 2; i++ {
createReadyTask(t, s, fmt.Sprintf("task-%d", i))
}
task1 := createReadyTask(t, s, "task-0")
task2 := createReadyTask(t, s, "task-1")
w := &Worker{
Store: s,
@@ -829,12 +856,12 @@ func TestWorkerSemaphore(t *testing.T) {
w.sem = make(chan struct{}, 1)
w.sem <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// первый poll — запустит 1 задачу (макс. 1)
w.pollAndDispatch(ctx)
time.Sleep(200 * time.Millisecond)
waitTaskStatus(t, ctx, s, task1.ID, storage.StatusSuccess)
// 1 должна быть success, 1 — всё ещё approved
success, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
@@ -848,7 +875,7 @@ func TestWorkerSemaphore(t *testing.T) {
// первая завершилась и вернула токен в сем — можем диспатчить вторую
w.pollAndDispatch(ctx)
time.Sleep(200 * time.Millisecond)
waitTaskStatus(t, ctx, s, task2.ID, storage.StatusSuccess)
success, _ = s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
if len(success) != 2 {