Compare commits
53 Commits
feat/200ac
...
23bae9a68f
| Author | SHA1 | Date | |
|---|---|---|---|
| 23bae9a68f | |||
|
|
70287140ec | ||
|
|
d9d043ec8e | ||
| b87092aa98 | |||
|
|
ee47785e1a | ||
|
|
a762a1df80 | ||
|
|
ee76f9d89f | ||
|
|
37cc29e4cd | ||
|
|
180afb1336 | ||
| 0c88272bf0 | |||
|
|
9420b276e9 | ||
| de21ec9f31 | |||
|
|
5c82229a00 | ||
|
|
612446d65b | ||
| 307a2b4173 | |||
|
|
8ba1a8aa00 | ||
|
|
5c93642cf8 | ||
|
|
4c44956d3e | ||
|
|
6274aad369 | ||
|
|
fdd73cab61 | ||
| 60a6966a95 | |||
|
|
c2272137b3 | ||
|
|
baf2ad7147 | ||
|
|
66e40d384e | ||
|
|
4957a5552e | ||
|
|
1f7ab9a67e | ||
| 408d137747 | |||
|
|
be4d749c45 | ||
| 0b31e18d85 | |||
|
|
b5fb583c90 | ||
| 3c8528dbd9 | |||
|
|
9f64be4ea5 | ||
| ad025c1668 | |||
|
|
1459670ce9 | ||
|
|
7eb3a0292c | ||
|
|
a64e3d6cc3 | ||
|
|
774ebf135a | ||
|
|
e1167c9537 | ||
|
|
ad9c2dd522 | ||
|
|
f77a6000c7 | ||
|
|
ae00556923 | ||
|
|
baf7179085 | ||
|
|
733e63339a | ||
|
|
b60978121d | ||
|
|
2f26b6ae88 | ||
| ed11879cbd | |||
|
|
631387fda7 | ||
|
|
07bae203c3 | ||
| 18b48bfdc4 | |||
|
|
7c7afa6875 | ||
|
|
43266ea04f | ||
|
|
f12ff04966 | ||
| a8a2de12c3 |
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
43
.serena/memories/conventions.md
Normal file
43
.serena/memories/conventions.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# conventions
|
||||||
|
|
||||||
|
## Стиль / кодстайл
|
||||||
|
- Стандартный Go-стиль; `gofmt`/`go fmt ./...`. Документация-комментарии и
|
||||||
|
package-doc на русском языке.
|
||||||
|
- Типизация: строгие типы, интерфейсы для абстракций (Decider, ui.Store, events.Event, chat.Channel).
|
||||||
|
- Свой тип `config.Duration` для времени (YAML-строки "5s"/"10m"), метод `.Duration()`.
|
||||||
|
- UI: uni-directional data flow — UI читает снапшоты (копии, `ui.Store`), не мутирует storage/core.
|
||||||
|
Ядро публикует события в `events.Bus`, UI подписывается `events.Hub` → колбэки в UI-поток через `fyne.Do`.
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
- Ошибки классифицируются по идентификаторам классов в исходниках (см. ниже).
|
||||||
|
- Ошибка-обёртка: `fmt.Errorf("%w: %v", ErrXxx, err)`.
|
||||||
|
- `errors.Join` для склейки нескольких ошибок валидации (config.Validate).
|
||||||
|
|
||||||
|
## Классы ошибок (маркируются в коде, документированы в README)
|
||||||
|
| Блок | Коды | Где |
|
||||||
|
|---|---|---|
|
||||||
|
| C | C1–C4 | internal/config |
|
||||||
|
| A | A1–A4 | internal/analyst; A (app): A1–A4 в internal/app |
|
||||||
|
| M | M1–M5 | internal/chat |
|
||||||
|
| D | D1–D5 | internal/core (ErrDecideFailed, ErrMaxTurns, ErrCommandUnknown…) |
|
||||||
|
| O | O1–O5 | internal/opencode |
|
||||||
|
| S | S1–S5 | internal/storage |
|
||||||
|
| W | W1–W5 | internal/worker |
|
||||||
|
| E | E1–E4 | internal/worker (репозитории) |
|
||||||
|
| R | R1–R6 | internal/worker/review_errors.go |
|
||||||
|
| U | U1–U6 | internal/update |
|
||||||
|
|
||||||
|
## Архитектурные конвенции
|
||||||
|
- **Composition root** — internal/app; подсистемы собираются там. UI-окно создаётся через
|
||||||
|
`newUIWindow(store)` (build-tag: cgo → desktop.New, !cgo → nil/headless). Флаг `--noui` выключает окно.
|
||||||
|
- **Слои:** model (домен) ← storage/events/UI зависят только от model; core не знает про UI.
|
||||||
|
- **Агенты** (analyst.md/dev.md/reviewer.md) — markdown-промпты, go:embed, распаковка в config_dir.
|
||||||
|
Вердикт — строгий JSON.
|
||||||
|
- Репо-клонирование/воркеры — через gitops; ветки задач `feat/<taskTag>` (см. mem:core).
|
||||||
|
- Обратная совместимость: поле `Repo` (одиночный) и `Repos` (список); EffectiveRepos/
|
||||||
|
SetReposFromDB/ReposJoined в storage/models.go.
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
- `app.Version` — семантическая major.minor.patch (ручной инкремент: patch=фиксы,
|
||||||
|
minor=новая обратно-совместимая функциональность, major=несовместимые изменения). Сейчас 0.2.2.
|
||||||
|
- `main.version` (ldflag) — build-идентификатор `commit-<sha7>`, отдельно от app.Version.
|
||||||
57
.serena/memories/core.md
Normal file
57
.serena/memories/core.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# core
|
||||||
|
|
||||||
|
Ratatoskr-go — оркестратор конвейера Ratatoskr (порт с Python на Go) в единый
|
||||||
|
бинарь. Субагенты (analyst/dev/reviewer) запускаются через внешний процесс
|
||||||
|
[opencode](https://opencode.ai) (v2 HTTP API). Интерфейсы: Telegram-бот + Fyne UI.
|
||||||
|
Linux — статический headless-бинарь (CGO_ENABLED=0); Windows — с Fyne-окном (cgo).
|
||||||
|
|
||||||
|
## Структура (модули internal/)
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/ratatoskr/ точка входа; флаги: -config, -version, -noui (headless). main.version/updateToken вшиваются ldflag'ом
|
||||||
|
internal/
|
||||||
|
app/ composition root/DI: App.New -> config.Load+Validate, ResolveExePaths, storage, Runner, Analyst, Core, Worker, Updater, UI. packageOwner="kamelion", Version="0.2.2"
|
||||||
|
config/ YAML+env загрузка (${VAR:-default}), defaults, validate C1-C4
|
||||||
|
model/ доменные типы/контракты: Status + validTransitions (IsValidTransition/IsTerminal), TraceStatus. Нижний слой — не зависит от storage/events
|
||||||
|
chat/ мультиканальный Router; telegram — long-poll канал. Коды M1-M5
|
||||||
|
core/ state-machine задач + Decider/analyst интерфейс. Коды D3/D4
|
||||||
|
analyst/ аналитик: промпт + разбор JSON-вердикта (opencode agent). Коды A1-A4
|
||||||
|
events/ шина событий UI: Bus (buffered Sub/Pub), типизированный Hub, события TaskCreated/TaskStatusChanged/HistoryAppended/TraceAppended/AgentActivity, LogBus (панель «Логи»)
|
||||||
|
worker/ polling-планировщик + dev/reviewer-конвейер + gitops. Коды W*, E*, R*
|
||||||
|
agents/ встроенные opencode-агенты (analyst.md, dev.md, reviewer.md) через go:embed
|
||||||
|
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
|
||||||
|
ui/ desktop-оболочка: Store-абстракция (копии), Commands (UI->Core), Controller (шины->View), View/Window интерфейсы
|
||||||
|
ui/desktop/ Fyne-реализация окна (build-tag cgo, --noui / нет cgo → headless)
|
||||||
|
scripts/ build-publish-ui.ps1 — локальная Windows-сборка Fyne-бинаря + публикация в Gitea
|
||||||
|
docs/ ui-spec.md — спека Fyne UI (слои, event-bus, fyne.Do/снапшоты)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ключевые инварианты
|
||||||
|
|
||||||
|
- **App.New-сигнатура:** `App.New(configPath, version, updateToken string, noUI bool)` (4-й параметр — headless; cgo-вариант собирается только при наличии C-компилятора).
|
||||||
|
- **UI:** окно — ещё одна реализация `chat.Channel` (присоединяется в Router). Core не трогает UI; обмен — событийная шина (events). Кнопка «Завершить» = полный выход (SetOnQuit→cancel→UI.Run возвращается); закрытие крестиком = сворачивание, Core живёт. UI собирается с `--noui`/без cgo.
|
||||||
|
- **Фазы аналитика (Decision.Phase):** `ask`, `propose`, `ready` (два последних обрабатываются одинаково в core), `abort`. Требования валидатора: ask — chat_reply/questions; propose — хотя бы одно изменённое поле; ready — без изменённых полей.
|
||||||
|
- **Статусы задач (internal/model):** draft→collecting→ready→approved→running→success/failed/timeout + cancelled/aborted/closed (терминальные). UserID — chat.ID (одна активная задача на чат).
|
||||||
|
- **Decider/Worker/Analyst/Reviewer:** Decider=analyst интерфейс; Worker — polling-планировщик; Reviewer проверяет diff dev-ветки (R1-R6), вердикт JSON {passed, critical_issues, solid_violations, comments}.
|
||||||
|
- **gitops (worker):** worktree-режим; feature-ветка `feat/<taskTag>` от origin/main; push через http.extraHeader, токен Bearer.
|
||||||
|
- **Пути «всё рядом с .exe»:** db/worktree резолвятся от ExeDir; config.yaml — рядом с бинарём, фоллбэк cwd.
|
||||||
|
- **Автообновление:** авто = только Check+уведомление; замена — по /update; версии в `commit-<sha7>/` (не `latest/`); Verify сверяет предprod-версию (binary+в.в) .
|
||||||
|
|
||||||
|
## opencode (v2 HTTP API, >= 1.18.18)
|
||||||
|
|
||||||
|
- Интеграция с субагентами — через headless `opencode serve`, **v2 API** (`/api/*`). Версия opencode >= 1.18.18.
|
||||||
|
- **Хардпин модели:** при создании сессии читается top-level `model` из конфига opencode (`internal/opencode/config.go`, JSONC-стрип) и передаётся в `POST /api/session` как `{"model":{providerID,id}}`.
|
||||||
|
- **О5 WARN (устойчивость к v1-конфигу):** конфиг по старой схеме молча игнорируется v2; провайдер без api → unsupported модели → fallback. Ratatoskr не чинит сам, но логирует warning; фактическая модель ответа сравнивается с ожидаемой. Правильный 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.
|
||||||
|
|
||||||
|
## Контракты (не ломать)
|
||||||
|
|
||||||
|
- `App.New(configPath, version, updateToken string, noUI bool)` — сигнатура.
|
||||||
|
- `packageOwner` — константа "kamelion" (не плодить vars/ldflag/конфиг).
|
||||||
|
- `update.Updater` — создаётся структурой `&update.Updater{...}`, конструктора нет.
|
||||||
|
- `ui.Store` — чтение-модель для UI: ListTasks/GetTask/GetHistory/GetTraces (копии, без ссылок на storage).
|
||||||
|
- `events.Bus`/`events.LogBus` — шина UI (см. internal/events). publisher-интерфейсы подключаются к Core/Worker.
|
||||||
|
|
||||||
|
см. также `mem:tech_stack`, `mem:conventions`, `mem:task_completion`, `mem:suggested_commands`.
|
||||||
33
.serena/memories/memory_maintenance.md
Normal file
33
.serena/memories/memory_maintenance.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Memory Maintenance
|
||||||
|
|
||||||
|
## Discovery Model
|
||||||
|
|
||||||
|
- Core principle: progressive discovery through references, building a graph of memories.
|
||||||
|
- Initially, agents are provided with the list of all memories (names only).
|
||||||
|
- Agents should read `mem:core` as the top-level entry point (graph root).
|
||||||
|
This memory should contain references to other memories covering major project domains.
|
||||||
|
The referenced memories shall, in turn, shall contain references to even more specific memories, and so on.
|
||||||
|
The depth of the graph shall depend on the project complexity.
|
||||||
|
- Use topics/folders to group related memories in order to make the content structure explicit.
|
||||||
|
Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc.
|
||||||
|
- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`.
|
||||||
|
The surrounding text should clearly indicate when to read the memory/which content to expect.
|
||||||
|
The text should provide more precise guidance than the memory name alone,
|
||||||
|
i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered.
|
||||||
|
- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
Dense agent notes, not prose docs. Prefer invariants, terse bullets.
|
||||||
|
Avoid obvious context, rationale, and examples unless they prevent likely mistakes.
|
||||||
|
Keep guidance durable and generalizable, not task-local.
|
||||||
|
|
||||||
|
## Add/update threshold
|
||||||
|
|
||||||
|
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
|
||||||
|
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
|
||||||
|
|
||||||
|
## Maintenance Actions
|
||||||
|
|
||||||
|
- Renaming memories: References are updated automatically if handled via Serena's memory rename tool.
|
||||||
|
- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report.
|
||||||
32
.serena/memories/suggested_commands.md
Normal file
32
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# suggested_commands
|
||||||
|
|
||||||
|
## Сборка / проверки (из корня репо)
|
||||||
|
- `go test ./... -v -count=1 -timeout 120s` (или `make test`) — все тесты.
|
||||||
|
- `go vet ./...` (или `make vet`).
|
||||||
|
- `go fmt ./...` (или `make fmt`).
|
||||||
|
- `go build -o ratatoskr ./cmd/ratatoskr/` (или `make build`).
|
||||||
|
- `./ratatoskr -config config.yaml` (или `make run`).
|
||||||
|
- `./ratatoskr -version` — версия бинаря; `./ratatoskr -noui` — headless (без окна).
|
||||||
|
- Кросс-сборка: `make cross` (linux/amd64 + windows/amd64, headless).
|
||||||
|
|
||||||
|
## Windows / UI (Fyne)
|
||||||
|
- Сборка с UI (cgo): `powershell -ExecutionPolicy Bypass -File scripts/build-publish-ui.ps1`.
|
||||||
|
Требует `scripts/.gitea-creds` (не коммитить) и MinGW/gcc в PATH. См. `mem:toolchain/cgo-winlibs-gcc`.
|
||||||
|
- Обычная headless-сборка на Windows без cgo: `go build -o ratatoskr.exe ./cmd/ratatoskr/`.
|
||||||
|
|
||||||
|
## Go-тулчейн
|
||||||
|
Хост без `go` в PATH — экспорт вручную:
|
||||||
|
```
|
||||||
|
export PATH=/opt/data/.local/go/bin:$PATH
|
||||||
|
```
|
||||||
|
На машине разработки (Windows) — обычный system Go.
|
||||||
|
|
||||||
|
## git (worktree-процесс Ratatoskr)
|
||||||
|
- Feature-ветка: `feat/<taskTag>`, база — `origin/main`. Проверка diff всей ветки:
|
||||||
|
`git diff origin/main...HEAD`.
|
||||||
|
- Проверить состав отслеживаемых файлов (например наличие .serena): `git ls-files`.
|
||||||
|
- Статус: `git status`. Лог: `git log --oneline -10`.
|
||||||
|
|
||||||
|
## Замечания про среду
|
||||||
|
- ОС Windows + PowerShell 5.1 (shell: powershell) — нет `&&`; использовать `;`/`if ($?) {}`;
|
||||||
|
`&` для вызова путей с пробелами. Для серенных файлов лучше отдельная команда `serena memories check`.
|
||||||
16
.serena/memories/task_completion.md
Normal file
16
.serena/memories/task_completion.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# task_completion
|
||||||
|
|
||||||
|
Считается, что задача по коду выполнена строго после:
|
||||||
|
|
||||||
|
1. **Формат/линт:** `go fmt ./...` (без неотформатированных файлов).
|
||||||
|
2. **Тесты:** `go test ./... -v -count=1 -timeout 120s` — все проходят (`make test`).
|
||||||
|
3. **Статический анализ:** `go vet ./...` — чисто (`make vet`).
|
||||||
|
4. **Сборка:** `go build -o ratatoskr ./cmd/ratatoskr/` (`make build`) — компилируется.
|
||||||
|
- UI-сборка (cgo+Fyne) проверяется отдельно (`scripts/build-publish-ui.ps1` или `go build` с MinGW);
|
||||||
|
headless-сборка cgo-пакетов (`internal/ui/desktop`) — только typecheck.
|
||||||
|
5. **Пересмотр контрактов:** если менялась сигнатура `App.New(configPath, version,
|
||||||
|
updateToken string, noUI bool)` — обновить все вызовы (app_test.go, main.go).
|
||||||
|
6. Коммит осмысленными атомарными коммитами в feature-ветку `feat/<taskTag>` от origin/main.
|
||||||
|
|
||||||
|
Рабочий процесс Ratatoskr (агент dev в этом конвейере): изучить код, реализовать так, чтобы
|
||||||
|
все acceptance criteria были закрыты, закоммитить в ветку, вернуть отчёт.
|
||||||
32
.serena/memories/tech_stack.md
Normal file
32
.serena/memories/tech_stack.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# tech_stack
|
||||||
|
|
||||||
|
## Язык / рантайм
|
||||||
|
- Go **1.25.0** (go.mod `go 1.25.0`, module `github.com/kamelion/ratatoskr-go`).
|
||||||
|
- Headless-сборка — статический бинарь `CGO_ENABLED=0`. Windows-сборка с UI — cgo (Fyne/GLFW) + MinGW-w64/gcc.
|
||||||
|
- Локальный Go-тулчейн на хосте: `export PATH=/opt/data/.local/go/bin:$PATH` (go1.25.0 linux/amd64). На Windows — обычный system Go.
|
||||||
|
|
||||||
|
## Основные зависимости (go.mod)
|
||||||
|
- `fyne.io/fyne/v2 v2.6.0` — desktop UI (cgo/GLFW; платформа Windows + Linux). Не собирается в CI (headless).
|
||||||
|
- `gopkg.in/yaml.v3 v3.0.1` — парсинг config.yaml.
|
||||||
|
- `modernc.org/sqlite v1.56.0` — SQLite без CGO (чистый Go).
|
||||||
|
- (indirect) google/uuid, go-humanize, go-text/typesetting, x/sys, x/net, modernc.org/libc и пр.
|
||||||
|
|
||||||
|
## Внешние процессы
|
||||||
|
- **opencode** (opencode.ai) — субагенты (analyst/dev/reviewer). Управляется через internal/opencode (Runner, Pool, LiveRegistry). Требуемая версия **>= 1.18.18** (v2 HTTP API `/api/*`).
|
||||||
|
- Настройки в конфиге: `opencode.bin / config / config_dir / hard_timeout / idle_timeout / poll_ms` + `opencode.server` (hostname, port, password).
|
||||||
|
- Подробно — `mem:core`.
|
||||||
|
|
||||||
|
## Сборка / Makefile
|
||||||
|
- `make build` — go build `-ldflags="-s -w -X main.version=commit-<sha7> -X main.updateToken=..." -o ratatoskr ./cmd/ratatoskr/`.
|
||||||
|
- `make test` — `go test ./... -v -count=1 -timeout 120s`.
|
||||||
|
- `make vet` — `go vet ./...`; `make fmt` — `go fmt ./...`.
|
||||||
|
- `make run` — build + `./ratatoskr -config config.yaml`.
|
||||||
|
- `make cross` — linux/amd64 + windows/amd64 (headless, БЕЗ Fyne-UI; для UI нужен cgo).
|
||||||
|
- **Fyne-UI (Windows) собирается вручную** на машине с MinGW:
|
||||||
|
`scripts/build-publish-ui.ps1` (cgo) — сборка + публикация в Gitea в версию `commit-<sha7>`.
|
||||||
|
Требует `scripts/.gitea-creds` (git-ignored) и gcc в PATH. См. `mem:toolchain/cgo-winlibs-gcc`.
|
||||||
|
|
||||||
|
## CI (.gitea/workflows/ci.yaml)
|
||||||
|
- Job `test`: `go test ./...` + `go vet ./...` (setup-go '1.23', check-latest).
|
||||||
|
- Job `build-and-package` (matrix linux/amd64 + windows/amd64): собирает headless-бинарь с ldflag update_token, публикует на `main` в версию `commit-<sha7>/` (+ companion `.version`/`.sha256`).
|
||||||
|
- Секреты: `TC_GITEA_TOKEN` (write:packages), `TC_UPDATE_TOKEN` (read:package), vars `GIT_MAIN_URL`.
|
||||||
14
.serena/memories/toolchain/cgo-winlibs-gcc.md
Normal file
14
.serena/memories/toolchain/cgo-winlibs-gcc.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# cgo/winlibs-gcc
|
||||||
|
|
||||||
|
## MinGW/WinLibs для Fyne-сборки на Windows
|
||||||
|
- UI (internal/ui/desktop, fyne.io/fyne/v2) требует cgo + C-компилятор для GLFW.
|
||||||
|
- Установлен постоянно в `D:\MinGW\bin` (WinLibs x86_64-ucrt-posix-seh, GCC 16.2.0),
|
||||||
|
файл `D:\MinGW\bin\gcc.exe` существует. НО каталог не гарантированно в PATH текущей
|
||||||
|
сессии (проверено 2026-08-21: `D:\MinGW\bin` отсутствовал в PATH, `gcc` не находился).
|
||||||
|
`scripts/build-publish-ui.ps1` сам проверяет gcc и при отсутствии подсказывает ручную
|
||||||
|
подстановку PATH (стр. 36–41): `$env:Path = "<...>\mingw64\bin;$env:Path"`.
|
||||||
|
- Прежняя winget-версия (BrechtSanders.WinLibs.POSIX.UCRT) удалена, чтобы не было дубликата.
|
||||||
|
- Проверка: `gcc --version`; также убедиться, что `CGO_ENABLED` не = "0".
|
||||||
|
- Care: не использовать Cygwin/MSYS-сборки gcc — только native MinGW-w64 (WinLibs).
|
||||||
|
- Сборка UI выполняется локально на Windows-машине, публикуется в Gitea Packages
|
||||||
|
в в версию `commit-<sha7>` со companion-.version/.sha256 (контракт U4) — см. скрипт и `mem:tech_stack`.
|
||||||
169
.serena/project.yml
Normal file
169
.serena/project.yml
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
||||||
|
project_name: "ratatoskr-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"
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# advanced configuration option allowing to configure language server-specific options.
|
||||||
|
# Maps the language key to the options.
|
||||||
|
# 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 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: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude.
|
||||||
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||||
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
|
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||||
|
# for this project.
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||||
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
|
added_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# and cannot be accessed via read_memory or write_memory.
|
||||||
|
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||||
|
# 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
|
||||||
31
README.md
31
README.md
@@ -17,7 +17,7 @@ internal/
|
|||||||
analyst/ # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1–A4)
|
analyst/ # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1–A4)
|
||||||
worker/ # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*)
|
worker/ # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*)
|
||||||
agents/ # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed
|
agents/ # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed
|
||||||
opencode/ # обёртка запуска opencode-процесса, парсинг вердикта (классы O1–O4)
|
opencode/ # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1–O5)
|
||||||
storage/ # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1–S5)
|
storage/ # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1–S5)
|
||||||
update/ # автообновление из Gitea Packages (классы U1–U6)
|
update/ # автообновление из Gitea Packages (классы U1–U6)
|
||||||
```
|
```
|
||||||
@@ -129,6 +129,33 @@ update:
|
|||||||
| `/status` | версия бинаря + есть ли доступное обновление |
|
| `/status` | версия бинаря + есть ли доступное обновление |
|
||||||
| `/help` | справка по всем командам |
|
| `/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`:
|
Аналитик (`internal/analyst`) возвращает JSON-вердикт с полем `phase`:
|
||||||
@@ -198,7 +225,7 @@ curl -s -H "Authorization: token $TOKEN" "$B/api/packages/kamelion/generic/ratat
|
|||||||
| C | C1–C4 | `internal/config` |
|
| C | C1–C4 | `internal/config` |
|
||||||
| A | A1–A4 | `internal/analyst` |
|
| A | A1–A4 | `internal/analyst` |
|
||||||
| M | M1–M5 | `internal/chat` |
|
| M | M1–M5 | `internal/chat` |
|
||||||
| O | O1–O4 | `internal/opencode` |
|
| O | O1–O5 | `internal/opencode` |
|
||||||
| S | S1–S5 | `internal/storage` |
|
| S | S1–S5 | `internal/storage` |
|
||||||
| W | W1–W5 | `internal/worker` |
|
| W | W1–W5 | `internal/worker` |
|
||||||
| E | E1–E4 | `internal/worker` (репозитории) |
|
| E | E1–E4 | `internal/worker` (репозитории) |
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ var updateToken = ""
|
|||||||
func main() {
|
func main() {
|
||||||
cfg := flag.String("config", "", "путь к config.yaml (по умолчанию — CWD/config.yaml)")
|
cfg := flag.String("config", "", "путь к config.yaml (по умолчанию — CWD/config.yaml)")
|
||||||
versionFlag := flag.Bool("version", false, "показать версию и выйти")
|
versionFlag := flag.Bool("version", false, "показать версию и выйти")
|
||||||
|
noUI := flag.Bool("noui", false, "работать без графического окна (headless)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *versionFlag {
|
if *versionFlag {
|
||||||
@@ -34,7 +35,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a, err := app.New(*cfg, version, updateToken)
|
a, err := app.New(*cfg, version, updateToken, *noUI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("app init: %v", err)
|
log.Fatalf("app init: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,3 +34,7 @@ telegram:
|
|||||||
# paths:
|
# paths:
|
||||||
# worktree: "./worktrees"
|
# worktree: "./worktrees"
|
||||||
# db: "./ratatoskr.db" # или через env RATATOSKR_DB
|
# db: "./ratatoskr.db" # или через env RATATOSKR_DB
|
||||||
|
|
||||||
|
# log (уровень логирования)
|
||||||
|
# level: "info" # "info" (по умолчанию) или "debug" — debug включает
|
||||||
|
# # отладочные логи (напр. все API-вызовы к opencode serve)
|
||||||
|
|||||||
252
docs/ui-spec.md
Normal file
252
docs/ui-spec.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# UI-спека Ratatoskr (Fyne Desktop)
|
||||||
|
|
||||||
|
Статус: утверждается. Черновик, обсуждаем дальше (архитектура обмена UI <-> Core).
|
||||||
|
|
||||||
|
Стек: **Go + Fyne** (свежая стабильная версия). Платформа: **Windows** (Linux пока не поддерживаем).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цель и роль UI
|
||||||
|
|
||||||
|
1. UI — **полноценный инструмент** (не только админ-панель): управляет всем, чем управляет Telegram, и становится **основным** интерфейсом.
|
||||||
|
2. Один пользователь, один человек (без multi-user / мультисессии).
|
||||||
|
3. UI должен уметь **всё, что умеет Telegram-интерфейс**, и станет основным; Telegram остаётся как один из каналов.
|
||||||
|
4. UI — **графическая оболочка самого приложения** (одна программа), не отдельный process.
|
||||||
|
5. Внутри оболочки запускается **ботик сам**; UI — это лишь ещё одна реализация `chat.Channel`.
|
||||||
|
6. Запуск: **обычный запуск бинаря** → UI. Режим без UI — только по флагу **`--noui`**.
|
||||||
|
- Логика: UI включён по умолчанию, headless — опционально (`--noui`).
|
||||||
|
7. Трей-иконка не нужна.
|
||||||
|
8. При закрытии окна — **сворачивать** (не завершать процесс). Для полного выхода — **отдельная кнопка** «Завершить», чтобы бот остановился.
|
||||||
|
9. Конфиг-секции редактировать в UI пока не нужно; **hot-reload конфига** — желательно, если реализуемо (запланировать как «если возможно — да»).
|
||||||
|
|
||||||
|
## 2. Компоновка (layout)
|
||||||
|
|
||||||
|
- Базовый grid по умолчанию:
|
||||||
|
- **слева — список задач** (широкая колонка),
|
||||||
|
- **по центру/право — рабочая область** (в ней живёт «работа бота» / live-шаги);
|
||||||
|
- рабочая область может **разделяться на сплит-панели** (2×2, не считая левой колонки с задачами).
|
||||||
|
- Табы пока **не делаем** (первый этап).
|
||||||
|
- Панели должны быть **сворачиваемыми** (хотя бы левая колонка с задачами).
|
||||||
|
- Сохранение layout (положение/размеры панелей) — **нужно**, переживает перезапуск приложения (Fyne Preferences).
|
||||||
|
- Окно: **на весь экран по умолчанию**.
|
||||||
|
- Нижняя панель: **вкладки «Логи», «Состояние»**:
|
||||||
|
- **Логи** — ведение логов (поток системных логов);
|
||||||
|
- **Состояние** — живое состояние текущих задач / на каком статусе сейчас задача.
|
||||||
|
- Верхняя панель: **меню**, пока только пункт «О программе» (About → About).
|
||||||
|
- Тема: **тёмная**, без переключателя темы; стартуем с **дефолтной темы Fyne**.
|
||||||
|
- Локаль: **en** (интерфейс на английском).
|
||||||
|
|
||||||
|
## 3. Данные задач
|
||||||
|
|
||||||
|
- Задачи отображаются **списком** слева: **краткое название + служебное** (воркшоп).
|
||||||
|
- Детализация списка (статус/фильтры/поиск/сортировка/по умолчание/пагинация) — **дорабатываем потом**.
|
||||||
|
- В деталях задачи (рабочая область) показываем все перечисленные поля:
|
||||||
|
- Title, Goal, Why, AC (acceptance criteria), Repo(s), Tag (task_tag), статус, CreatedAt/UpdatedAt, вложенные трассы (traces), история чата (history), включая (session id opencode).
|
||||||
|
- История чата из БД (user/assistant) — лента сообщений.
|
||||||
|
- Трейсы агентов (аналист/dev/reviewer) — дерево шагов/ходов + raw output.
|
||||||
|
- Live-шаги (LiveRegistry) — восстановить и показывать в реальном времени.
|
||||||
|
|
||||||
|
## 4. Действия с задачами
|
||||||
|
|
||||||
|
- UI поддерживает все действия, доступные в TG: создание задачи, изменение, **approve**, **rework доработка**, **cancel**, **retry**, закрытие (skip/continue), статусы — в рамках валидных переходов.
|
||||||
|
- Создание задачи — форма с полями (Title, Goal/Why, Repos, AC) (какие обязательные — уточнить).
|
||||||
|
- Работа с статусами: эмуляция команд `/approve`, `/retry`, `/cancel` и т.п. (валидные переходы по state machine)
|
||||||
|
- Подтверждения опасных действий (удаление задачи, и т.п.).
|
||||||
|
- Редактирование полей (repo, AC, tags) из UI — по необходимости.
|
||||||
|
|
||||||
|
## 7. Мониторинг системы
|
||||||
|
|
||||||
|
- Показывать **логи** (готовый поток из `log`), **фреймы состояния** бота:
|
||||||
|
- статус opencode serve/pool, коннекции, активные сессии.
|
||||||
|
- Live-статус задач (`/status N`) эквивалентом — во вкладке «Состояние».
|
||||||
|
- Нужен индикатор занятости агента («опенкод думает/dev writing…»).
|
||||||
|
|
||||||
|
## 8. Настройки / конфиг
|
||||||
|
|
||||||
|
- Настройки в UI пока **не выводим**; секреты никоим образом не показываем.
|
||||||
|
- Кнопка «Завершить» (выход приложения) — в меню.
|
||||||
|
- Перезапуск бота из UI — пока не надо.
|
||||||
|
|
||||||
|
## 9. Живое обновление / консистентность
|
||||||
|
|
||||||
|
- **Шина событий (event-bus)** — запланирована: UI получает события из ядра (новые задачи, изменение статусов, live-шаги, логи). Явно нужен.
|
||||||
|
- UI и Telegram — **оболочки** вокруг одного ядра; обмен через **интерфейс (абстракцию)**, не зашумляя ядро.
|
||||||
|
- Консистентность между TG и UI при параллельных изменениях — через события ядра; архитектура обмена — см. раздел 12.
|
||||||
|
|
||||||
|
## 10. Технические
|
||||||
|
|
||||||
|
- **Fyne**: свежая стабильная (последняя).
|
||||||
|
- **Абстракция БД**: доступ к storage через **интерфейс** (не прямиком к *storage.Storage) для тестируемости.
|
||||||
|
- Пакет: **`internal/ui`** (+ подпакеты при необходимости).
|
||||||
|
- Тесты: юнит-тесты модели, **без e2e/UI-тестов** пока.
|
||||||
|
- Использование данных: **вся история** (без ограничения по времени).
|
||||||
|
|
||||||
|
## 11. Скоуп первого этапа
|
||||||
|
|
||||||
|
- Минимальный UI, покрывающий **функционал Telegram-интерфейса** (все команды/действия).
|
||||||
|
- Также UI — основной интерфейс (TG остаётся каналом).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Архитектура обмена UI ↔ Core (best practices)
|
||||||
|
|
||||||
|
### 12.1. Слои и направление зависимостей
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ команды → ┌─────────────────┐ заказы/готовое → ┌──────────────┐
|
||||||
|
│ UI (Fyne) │ ───────────▶ │ Application / │ ─────────────────▶ │ Domain/core │
|
||||||
|
│ «представ- │ │ UseCase слой │ события (events) │ (model) │
|
||||||
|
│ ление» │ ◀─────────── │ │ ◀────────────────── │ │
|
||||||
|
└──────────────┘ события ← └─────────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Core** (`internal/core`.ProcessTurn — state-machine) **не трогаем**. Он — источник правды.
|
||||||
|
- **App** (`internal/app`) — use-case/композиция: политика «одна активная задача на чат», `Notify`, роутинг команд.
|
||||||
|
- **UI** — ещё одна реализация `chat.Channel` (параллельно Telegram). Уже есть идеальный «port»: интерфейс `Channel{Run, OnMessage, Send, Ask, Close}`.
|
||||||
|
|
||||||
|
### 12.2. UI получает состояние, а не управляет Core'ом (uni-directional data flow)
|
||||||
|
|
||||||
|
- Core **мутирует состояние** (БД, worker, аналитик). UI — только **читает снимки** и реагирует на события, обновляя своё view-model (не БД и не core).
|
||||||
|
- Действия UI = **команды** (`CreateTask`, `ApproveTask`, `RetryTask`, `CancelTask`, `ContinueTask`), которые вызывают use-case в Core.
|
||||||
|
- Это даёт: единый источник правды, простую отладку, лёгкое тестирование без UI.
|
||||||
|
|
||||||
|
### 12.3. Обмен = событийная шина (event bus / pub-sub)
|
||||||
|
|
||||||
|
- **Core — публикует** доменные события, UI — **подписчик**:
|
||||||
|
- `TaskCreated{id, chatID}`
|
||||||
|
- `TaskStatusChanged{id, from, to}` (ready/approved/running/success/failed/timeout…)
|
||||||
|
- `TaskHistoryAppended{id, role, content}`
|
||||||
|
- `TraceAppended{id, agent, status, output}`
|
||||||
|
- `AgentActivity{id, agent, stage}` (live-шаги, индикаторы занятости)
|
||||||
|
- `LogLine` (системные логи для вкладки «Логи»)
|
||||||
|
- **Направление однонаправленное**: core/worker/analyst не знают, кто подписан; никаких прямых вызовов Fyne из core!
|
||||||
|
- Реализация малая: `Bus` с `Subscribe/Unsubscribe/Publish` + buffered channels (`n`-подписчиков или `select`); паттерн уже есть в `chat.Router` (внутренний `incoming` channel).
|
||||||
|
- Сигнатуры событий использовать **и для консистентности** TG↔UI (оба канала — простые подписчики/клиенты шины).
|
||||||
|
|
||||||
|
### 12.4. Модель потоков Fyne (v2.6+) и `fyne.Do`
|
||||||
|
|
||||||
|
- Fyne с v2.6.0 выполняет все события/колбэки **на одной главной goroutine**.
|
||||||
|
- Фоновые goroutine (worker, opencode polling, ход core) **не должны модифицировать UI напрямую**.
|
||||||
|
- Любое обновление виджетов из своей goroutine — через:
|
||||||
|
- `fyne.Do(func(){ widget.X = …; widget.Refresh() })` — очередь в следующий кадр;
|
||||||
|
- `fyne.DoAndWait(...)` — когда надо дождаться завершения;
|
||||||
|
- унифицировать с **binding** (`binding.String`, `binding.List`) — через `fyne.Do` не требуется, но проще явно.
|
||||||
|
- Подписчик шины ставит snapshot в очередь UI-обновления и вызывает `fyne.Do`.
|
||||||
|
|
||||||
|
### 12.5. Чтение-модель на UI: снапшоты, не мутабельные указатели
|
||||||
|
|
||||||
|
- UI получает **копии** записей (Task, history, traces) через абстракцию БД (см. раздел 10).
|
||||||
|
- Никаких референсов на разделяемые контейнеры core; используются лёгкие view-копии (Value-типы/DT-коды) — нет гонок bottom-up.
|
||||||
|
|
||||||
|
### 12.6. Жизненный цикл: Core ≠ UI
|
||||||
|
|
||||||
|
- Core (бот) живёт независимо от окна. Закрытие окна = **сворачивание** (Core продолжает).
|
||||||
|
- Полный выход — только кнопка «Завершить» → `app.Quit()` (Worker.Stop → router.Close → pool.Close → store.Close).
|
||||||
|
- `--noui` → core запускается без создания окна.
|
||||||
|
|
||||||
|
### 12.7. Скоуп реализации (поэтапно)
|
||||||
|
|
||||||
|
- [x] **Фаза 1**: `internal/ui` — Store-абстракция (копии), окно как `chat.Channel`,
|
||||||
|
команды через канал, snapshots из БД. Fyne-реализация — `internal/ui/desktop`
|
||||||
|
(build-tag `cgo`, требует компилятор C для GLFW).
|
||||||
|
- [x] **Фаза 2**: `internal/events` — Bus/Hub, Core публикует события, UI подписывается
|
||||||
|
через `Controller` (статусы, история, live-шаги, логи).
|
||||||
|
- [ ] **Фаза 3**: консистентность TG↔UI через общий маршрутизатор/шину (`UserID`, `chat.Router`).
|
||||||
|
|
||||||
|
Примечание: окно (Фаза 1) собирается только с cgo; на машинах без C-компилятора
|
||||||
|
приложение работает headless (UI=nil). Реальное окно полноценно проверяется
|
||||||
|
на машине с MinGW-w64/gcc (`go build -ldflags ...`), здесь — только typecheck.
|
||||||
|
|
||||||
|
### 12.8. Структура UI-кода: компоненты-виджеты (рефакторинг)
|
||||||
|
|
||||||
|
`internal/ui` (без Fyne) — контракты + Nil-реализации + юнит-тесты. Fyne-код —
|
||||||
|
только в `internal/ui/desktop/*`, один файл — одна панель. `desktop/window.go`
|
||||||
|
становится чистой компоновкой.
|
||||||
|
|
||||||
|
Каждый компонент:
|
||||||
|
- **интерфейс** в `internal/ui/<panel>.go` (контракт для тестов/headless);
|
||||||
|
- **Nil-реализация** в том же файле (headless/тесты);
|
||||||
|
- **Fyne-реализация** в `internal/ui/desktop/<panel>.go`.
|
||||||
|
|
||||||
|
### 12.9. Контракт виджета «Логи» (LogPanel)
|
||||||
|
|
||||||
|
Панель «Логи» — поток системных логов из `events.LogLine` (уровень + строка).
|
||||||
|
|
||||||
|
- `Append(level, text)` — добавить запись лога; уровень нормализуется к одному
|
||||||
|
из четырёх (`ui.NormalizeLogLevel`: error/warning/info/debug, неизвестные →
|
||||||
|
info); при превышении лимита старые записи отбрасываются (буфер ограничен,
|
||||||
|
не расти бесконечно).
|
||||||
|
- **Фильтр уровней**: над лентой — выпадающий список с чекбоксами
|
||||||
|
(error/warning/info/debug). По умолчанию отмечены все уровни (виден полный
|
||||||
|
лог); показываются только записи выбранных уровней; изменение набора сразу
|
||||||
|
перерисовывает ленту — и по уже загруженным записям, и для вновь
|
||||||
|
поступающих. Если ни один уровень не выбран — лента пуста (без ошибок).
|
||||||
|
- Контейнер виджета — отдаётся окну для встраивания в таб «Логи» (строка
|
||||||
|
фильтра + Scroll с лентой).
|
||||||
|
- Обновление содержимого — на потоке Fyne (внутри панели `fyne.Do`), не из
|
||||||
|
горутины Hub.
|
||||||
|
|
||||||
|
### 12.10. Контракт виджета «Список задач» (TaskListPanel)
|
||||||
|
|
||||||
|
Список задач — левая панель окна: все задачи (`storage.Task`) строками.
|
||||||
|
|
||||||
|
- `SetTasks(tasks)` — заменить снимок списка и перерисовать виджет. Данные —
|
||||||
|
копии из Store (спец 12.5).
|
||||||
|
- `OnSelect(fn)` — колбэк клика по строке; окно грузит детали выбранной задачи
|
||||||
|
(это `task_detail`, отдельный компонент). Состояние выбора живёт в окне.
|
||||||
|
- Выбранная строка подсвечивается (`Select`/`Unselect`), окно управляет ею.
|
||||||
|
- Строка = `taskTitle(t)` (pure-функция, тестируется без Fyne).
|
||||||
|
- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`, панель сама
|
||||||
|
виджет не трогает из горутин Hub.
|
||||||
|
|
||||||
|
### 12.11. Контракт виджета «Детали задачи» (TaskDetailPanel)
|
||||||
|
|
||||||
|
Детали выбранной задачи — верх `right`-сплита: заголовок, статус, детали
|
||||||
|
(цель + репозитории).
|
||||||
|
|
||||||
|
- `ShowTask(t)` — рендер из снимка `storage.Task`: заголовок (bold), статус
|
||||||
|
(italic), детали = `TaskDetailText(t)`.
|
||||||
|
- `ShowEmpty()` — сброс к placeholder («—» / пусто) при недоступной задаче.
|
||||||
|
- Строка деталей = pure `TaskDetailText(t)` (цель + репо), тестируется без Fyne.
|
||||||
|
- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`.
|
||||||
|
- Диалог и «Состояние» — отдельные панели (chat_panel, state_panel), здесь
|
||||||
|
не участвуют; окно оркестрирует загрузку всех панелей по выбору задачи.
|
||||||
|
|
||||||
|
### 12.12. Контракт виджета «Диалог» (ChatPanel)
|
||||||
|
|
||||||
|
Диалог — транскрипт общения с выбранной задачей + композитор (поле ввода и
|
||||||
|
кнопки команд).
|
||||||
|
|
||||||
|
- Транскрипт:
|
||||||
|
- `SetTranscript(text)` — полный рендер истории выбранной задачи;
|
||||||
|
- `Append(text)` — добавить строку; при превышении лимита старые строки
|
||||||
|
отбрасываются (буфер ограничен);
|
||||||
|
- `Clear()` — очистить при недоступной задаче.
|
||||||
|
- Композитор: поле ввода + кнопки команд, подключённые к `ui.Commands` через
|
||||||
|
`SetCommands(c)` (ввод → `SendText`, кнопки → Start/Approve/Skip/Cancel).
|
||||||
|
- Доставка: окно (как chat.Channel) рендерит `Send`/`Ask`/историю через
|
||||||
|
`Append`; строка роли = pure `FormatRole(role)` (👤/🤖).
|
||||||
|
- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`.
|
||||||
|
|
||||||
|
### 12.13. Контракт виджета «Состояние» (StatePanel)
|
||||||
|
|
||||||
|
Панель «Состояние» — агенты + трейсы выбранной задачи: снимок и live-строки.
|
||||||
|
|
||||||
|
- `SetText(text)` — полный рендер снимка трасс выбранной задачи.
|
||||||
|
- `Append(text)` — добавить строку; при превышении лимита старые строки
|
||||||
|
отбрасываются (буфер ограничен).
|
||||||
|
- Строка снимка = pure `StateText(traces)` (агент: статус, session).
|
||||||
|
- Live-строки (чистые хелперы, окно зовёт `Append` с готовой строкой):
|
||||||
|
- `TraceLine(agent, status)` — добавлен/обновлён трейс субагента;
|
||||||
|
- `ActivityLine(agent, stage)` — смена этапа агента;
|
||||||
|
- `TaskStatusText(e)` — изменение статуса задачи (бейдж + ID).
|
||||||
|
- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Открытые пункты (TODO)
|
||||||
|
|
||||||
|
- [ ] Точно определить set сплит-панелей (2x2 центр) и как добавляются
|
||||||
|
- [ ] Обязательные поля формы создания задачи
|
||||||
|
- [ ] Подробности виджета списка задач (после первого мильстон)
|
||||||
|
- [x] Архитектура обмена UI<->Core (раздел 12 — принципы; детальные контракты событий/интерфейсов — следующая итерация)
|
||||||
31
go.mod
31
go.mod
@@ -3,17 +3,48 @@ module github.com/kamelion/ratatoskr-go
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.6.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.56.0
|
modernc.org/sqlite v1.56.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.11.0 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fredbi/uri v1.1.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/fyne-io/gl-js v0.1.0 // indirect
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0 // indirect
|
||||||
|
github.com/fyne-io/image v0.1.1 // indirect
|
||||||
|
github.com/fyne-io/oksvg v0.1.0 // indirect
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||||
|
github.com/go-text/render v0.2.0 // indirect
|
||||||
|
github.com/go-text/typesetting v0.2.1 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
|
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rymdport/portal v0.4.1 // indirect
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
golang.org/x/image v0.24.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
74
go.sum
74
go.sum
@@ -1,27 +1,99 @@
|
|||||||
|
fyne.io/fyne/v2 v2.6.0 h1:Rywo9yKYN4qvNuvkRuLF+zxhJYWbIFM+m4N4KV4p1pQ=
|
||||||
|
fyne.io/fyne/v2 v2.6.0/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
|
||||||
|
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
||||||
|
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||||
|
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||||
|
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||||
|
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
||||||
|
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
|
||||||
|
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||||
|
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||||
|
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||||
|
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
|
||||||
|
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||||
|
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||||
|
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||||
|
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||||
|
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||||
|
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||||
|
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
|
||||||
|
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var FS embed.FS
|
|||||||
//
|
//
|
||||||
// Держим в курсе: если добавить файл в каталог, он автоматически попадёт
|
// Держим в курсе: если добавить файл в каталог, он автоматически попадёт
|
||||||
// в FS, но для детерминированной распаковки список лучше дополнять здесь.
|
// в FS, но для детерминированной распаковки список лучше дополнять здесь.
|
||||||
var Names = []string{"analyst", "dev", "reviewer"}
|
var Names = []string{"analyst", "dev", "reviewer", "chat"}
|
||||||
|
|
||||||
// WriteTo распаковывает всех встроенных агентов в каталог dir/agents
|
// WriteTo распаковывает всех встроенных агентов в каталог dir/agents
|
||||||
// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.
|
// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.
|
||||||
|
|||||||
17
internal/agents/chat.md
Normal file
17
internal/agents/chat.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: chat
|
||||||
|
description: Свободный чат с Ratatoskr — прямой диалог с LLM без задачи и JSON-вердиктов
|
||||||
|
mode: primary
|
||||||
|
---
|
||||||
|
|
||||||
|
Ты — полезный ассистент в диалоговом режиме. Пользователь пишет тебе произвольные
|
||||||
|
сообщения; отвечай на них прямо и по делу, на русском языке, естественно.
|
||||||
|
|
||||||
|
Особенности:
|
||||||
|
1. Отвечай на вопросы пользователя, помогай с задачами разработки, поясняй код.
|
||||||
|
2. Не оборачивай ответ в JSON и не возвращай структурированные вердикты — это
|
||||||
|
свободный диалог, не конвейер задач Ratatoskr.
|
||||||
|
3. Если тебе не хватает контекста — задай уточняющий вопрос в обычном тексте.
|
||||||
|
4. Пиши кратко и по существу, если пользователь не просит развёрнуто.
|
||||||
|
|
||||||
|
Просто продолжай диалог.
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/core"
|
"github.com/kamelion/ratatoskr-go/internal/core"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,16 @@ type Analyst struct {
|
|||||||
Runner OpenCodeRunner
|
Runner OpenCodeRunner
|
||||||
Worktree string // каталог, откуда запускать opencode run
|
Worktree string // каталог, откуда запускать opencode run
|
||||||
Agent string // имя агента (default "analyst")
|
Agent string // имя агента (default "analyst")
|
||||||
|
|
||||||
|
// Events — издатель доменных событий для UI. nil — события выключены.
|
||||||
|
Events events.Publisher
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish отправляет доменное событие, если задан издатель.
|
||||||
|
func (a *Analyst) publish(e events.Event) {
|
||||||
|
if a.Events != nil {
|
||||||
|
a.Events.Publish(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalystResponse — структура JSON-ответа аналитика.
|
// AnalystResponse — структура JSON-ответа аналитика.
|
||||||
@@ -58,6 +69,8 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor
|
|||||||
agent = "analyst"
|
agent = "analyst"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.publish(events.AgentActivity{TaskID: draft.ID, Agent: agent, Stage: "decide"})
|
||||||
|
|
||||||
if len(history) == 0 && !force {
|
if len(history) == 0 && !force {
|
||||||
return core.Decision{}, fmt.Errorf("%w: пустая история диалога", ErrNotReady)
|
return core.Decision{}, fmt.Errorf("%w: пустая история диалога", ErrNotReady)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -19,8 +20,10 @@ import (
|
|||||||
"github.com/kamelion/ratatoskr-go/internal/chat/telegram"
|
"github.com/kamelion/ratatoskr-go/internal/chat/telegram"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/config"
|
"github.com/kamelion/ratatoskr-go/internal/config"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/core"
|
"github.com/kamelion/ratatoskr-go/internal/core"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/update"
|
"github.com/kamelion/ratatoskr-go/internal/update"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/worker"
|
"github.com/kamelion/ratatoskr-go/internal/worker"
|
||||||
)
|
)
|
||||||
@@ -49,7 +52,7 @@ const packageOwner = "kamelion"
|
|||||||
// не следует путать с build-идентификатором `main.version` (commit-<sha7>),
|
// не следует путать с build-идентификатором `main.version` (commit-<sha7>),
|
||||||
// который вшивается ldflag'ом и используется автообновлением. Здесь номер
|
// который вшивается ldflag'ом и используется автообновлением. Здесь номер
|
||||||
// поднимается вручную перед каждым релизом/публикацией новой сборки.
|
// поднимается вручную перед каждым релизом/публикацией новой сборки.
|
||||||
const Version = "0.1.0"
|
const Version = "0.2.2"
|
||||||
|
|
||||||
// App — собранный конвейер.
|
// App — собранный конвейер.
|
||||||
type App struct {
|
type App struct {
|
||||||
@@ -60,6 +63,45 @@ type App struct {
|
|||||||
Worker *worker.Worker
|
Worker *worker.Worker
|
||||||
Updater *update.Updater
|
Updater *update.Updater
|
||||||
tg *telegram.Channel // сохранена для Run
|
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
|
||||||
|
// FreeChat — свободные диалоги с LLM вне машины задач (in-memory).
|
||||||
|
FreeChat *FreeChat
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreeChat — свободный LLM-чат (без задачи). Каждый UserID (вкладка UI)
|
||||||
|
// держит свою opencode-сессию, чтобы контекст вкладки не смешивался.
|
||||||
|
// Транскрипт живёт в памяти окна; здесь только связка uid → opencode sessionID.
|
||||||
|
type FreeChat struct {
|
||||||
|
Runner analyst.OpenCodeRunner
|
||||||
|
Worktree string
|
||||||
|
sessions map[chat.UserID]string // uid → opencode sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFreeChat создаёт сервис свободного чата.
|
||||||
|
func NewFreeChat(runner analyst.OpenCodeRunner, worktree string) *FreeChat {
|
||||||
|
return &FreeChat{Runner: runner, Worktree: worktree, sessions: map[chat.UserID]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat отправляет текст пользователя свободной сессии в opencode (agent="chat")
|
||||||
|
// и возвращает ответ модели. Сессия продолжается (resume по sessionID), поэтому
|
||||||
|
// каждая вкладка ведёт свой независимый диалог.
|
||||||
|
func (f *FreeChat) Chat(ctx context.Context, uid chat.UserID, text string) (string, error) {
|
||||||
|
sid := f.sessions[uid]
|
||||||
|
res, err := f.Runner.Run(ctx, text, f.Worktree, "chat", sid)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
f.sessions[uid] = res.SessionID
|
||||||
|
return res.Stdout, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// New читает конфиг и собирает все зависимости.
|
// New читает конфиг и собирает все зависимости.
|
||||||
@@ -67,7 +109,7 @@ type App struct {
|
|||||||
// version — вшитая версия бинаря (ldflag -X main.version).
|
// version — вшитая версия бинаря (ldflag -X main.version).
|
||||||
// updateToken — вшитый токен read:package для авто-обновления
|
// updateToken — вшитый токен read:package для авто-обновления
|
||||||
// (ldflag -X main.updateToken); имеет приоритет над update.token из конфига.
|
// (ldflag -X main.updateToken); имеет приоритет над update.token из конфига.
|
||||||
func New(configPath, version, updateToken string) (*App, error) {
|
func New(configPath, version, updateToken string, noUI bool) (*App, error) {
|
||||||
cfg, err := config.Load(configPath)
|
cfg, err := config.Load(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrConfig, err)
|
return nil, fmt.Errorf("%w: %v", ErrConfig, err)
|
||||||
@@ -102,15 +144,24 @@ func New(configPath, version, updateToken string) (*App, error) {
|
|||||||
}
|
}
|
||||||
log.Printf("app: db opened %s", cfg.Paths.DB)
|
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 — один на аналитика и воркер
|
// OpenCode runner — один на аналитика и воркер
|
||||||
ocRunner := &opencode.Runner{
|
ocRunner := &opencode.Runner{
|
||||||
Bin: cfg.OpenCode.Bin,
|
Pool: ocPool,
|
||||||
DBPath: cfg.OpenCode.DBPath,
|
|
||||||
Config: cfg.OpenCode.Config,
|
|
||||||
ConfigDir: cfg.OpenCode.ConfigDir,
|
|
||||||
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
|
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
|
||||||
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
|
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
|
||||||
PollInterval: cfg.OpenCode.PollMs.Duration(),
|
PollInterval: cfg.OpenCode.PollMs.Duration(),
|
||||||
|
Debug: cfg.Log.Debug(),
|
||||||
Stdout: os.Stderr,
|
Stdout: os.Stderr,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,9 +183,39 @@ func New(configPath, version, updateToken string) (*App, error) {
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Store: store,
|
Store: store,
|
||||||
CoreCtx: coreCtx,
|
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)
|
router := chat.NewRouter(a.handleIncoming)
|
||||||
|
|
||||||
|
// UI-канал: ещё одна реализация chat.Channel (спец 12.1).
|
||||||
|
// Собирается только при наличии cgo и отсутствии --noui.
|
||||||
|
if !noUI {
|
||||||
|
win := newUIWindow(ui.NewDBStore(store))
|
||||||
|
if win != nil {
|
||||||
|
if err := router.Attach(win); err != nil {
|
||||||
|
store.Close()
|
||||||
|
return nil, fmt.Errorf("attach ui: %w", err)
|
||||||
|
}
|
||||||
|
a.UI = win
|
||||||
|
// Контроллер подписывается на шины и мостит события в окно (fyne.Do).
|
||||||
|
a.Controller = ui.New(a.Events, a.LogEvents, win)
|
||||||
|
a.Controller.Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Telegram-канал
|
// Telegram-канал
|
||||||
tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration())
|
tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration())
|
||||||
a.tg = tg
|
a.tg = tg
|
||||||
@@ -154,10 +235,15 @@ func New(configPath, version, updateToken string) (*App, error) {
|
|||||||
GitBaseURL: cfg.Git.BaseURL,
|
GitBaseURL: cfg.Git.BaseURL,
|
||||||
GitToken: cfg.Git.Token,
|
GitToken: cfg.Git.Token,
|
||||||
Live: live,
|
Live: live,
|
||||||
|
Notify: a, // авто-уведомления владельцу задачи через Router
|
||||||
|
Events: a.Events,
|
||||||
}
|
}
|
||||||
a.Router = router
|
a.Router = router
|
||||||
a.Worker = w
|
a.Worker = w
|
||||||
|
|
||||||
|
// Свободный LLM-чат (вкладки UI без задачи): общий Runner, свой sessionID на uid.
|
||||||
|
a.FreeChat = NewFreeChat(ocRunner, cfg.Paths.Worktree)
|
||||||
|
|
||||||
// /status N читает живое состояние сессии через liveProbe-адаптер.
|
// /status N читает живое состояние сессии через liveProbe-адаптер.
|
||||||
coreCtx.Live = &liveProbe{reg: live}
|
coreCtx.Live = &liveProbe{reg: live}
|
||||||
|
|
||||||
@@ -184,6 +270,19 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
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)
|
// Канал для проверки Telegram-ошибки (горутина оборачивает Run)
|
||||||
tgErr := make(chan error, 1)
|
tgErr := make(chan error, 1)
|
||||||
|
|
||||||
@@ -201,6 +300,14 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
// Авто-проверка обновления (только уведомление владельца; замена — по /update)
|
// Авто-проверка обновления (только уведомление владельца; замена — по /update)
|
||||||
a.startAutoCheck(ctx)
|
a.startAutoCheck(ctx)
|
||||||
|
|
||||||
|
// UI: окно блокирует главную горутину (спец 12.6). Кнопка «Завершить»
|
||||||
|
// отменяет ctx → fyneApp.Quit() → Run возвращается → graceful shutdown.
|
||||||
|
// Закрытие крестиком = сворачивание: Core продолжает работать.
|
||||||
|
if a.UI != nil {
|
||||||
|
a.UI.SetOnQuit(cancel)
|
||||||
|
return a.UI.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Ожидание сигнала или фатальной ошибки Telegram
|
// Ожидание сигнала или фатальной ошибки Telegram
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -288,6 +395,10 @@ func (a *App) handleIncoming(inc chat.Incoming) {
|
|||||||
}
|
}
|
||||||
a.send(ctx, uid, result.Reply)
|
a.send(ctx, uid, result.Reply)
|
||||||
|
|
||||||
|
case isUISession(uid):
|
||||||
|
// Свободная UI-вкладка (нет активной задачи): свободный LLM-чат.
|
||||||
|
a.freeChatReply(ctx, uid, text)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
a.send(ctx, uid, "Нет активной задачи. /start — создать новую.")
|
a.send(ctx, uid, "Нет активной задачи. /start — создать новую.")
|
||||||
}
|
}
|
||||||
@@ -326,6 +437,16 @@ func (a *App) send(ctx context.Context, uid chat.UserID, text string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify реализует worker.Notifier: авто-уведомление владельцу задачи через
|
||||||
|
// chat.Router.Send (переходы статусов и хендоффы dev↔reviewer со стороны воркера).
|
||||||
|
// Router nil (тесты без Router / ранняя инициализация) — тихо пропускаем.
|
||||||
|
func (a *App) Notify(ctx context.Context, taskID int64, chatID, text string) error {
|
||||||
|
if a.Router == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.Router.Send(ctx, chat.UserID(chatID), chat.Message{Text: text})
|
||||||
|
}
|
||||||
|
|
||||||
// cmdName извлекает команду (первое слово до пробела, нижний регистр).
|
// cmdName извлекает команду (первое слово до пробела, нижний регистр).
|
||||||
func cmdName(text string) string {
|
func cmdName(text string) string {
|
||||||
s := strings.TrimSpace(text)
|
s := strings.TrimSpace(text)
|
||||||
@@ -342,6 +463,26 @@ func hasArg(text string) bool {
|
|||||||
return i > 0 && strings.TrimSpace(s[i:]) != ""
|
return i > 0 && strings.TrimSpace(s[i:]) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUISession — true, если UserID принадлежит UI-вкладке-сессии (ui://sess-*).
|
||||||
|
func isUISession(uid chat.UserID) bool {
|
||||||
|
return strings.HasPrefix(string(uid), ui.SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeChatReply — ответ свободной LLM-сессии (вкладка UI без активной задачи).
|
||||||
|
func (a *App) freeChatReply(ctx context.Context, uid chat.UserID, text string) {
|
||||||
|
if a.FreeChat == nil {
|
||||||
|
a.send(ctx, uid, "Нет активной задачи. /start — создать новую.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply, err := a.FreeChat.Chat(ctx, uid, text)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("app: free chat %s: %v", uid, err)
|
||||||
|
a.send(ctx, uid, "Ошибка LLM-чата.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.send(ctx, uid, reply)
|
||||||
|
}
|
||||||
|
|
||||||
// ownerUID — куда слать авто-уведомления об обновлении.
|
// ownerUID — куда слать авто-уведомления об обновлении.
|
||||||
func (a *App) ownerUID() chat.UserID {
|
func (a *App) ownerUID() chat.UserID {
|
||||||
if a.Config.Telegram.ChatID != "" {
|
if a.Config.Telegram.ChatID != "" {
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ func TestNew(t *testing.T) {
|
|||||||
t.Setenv("RATATOSKR_DB", dbPath)
|
t.Setenv("RATATOSKR_DB", dbPath)
|
||||||
|
|
||||||
// Загружаем без config-файла (дефолты + env)
|
// Загружаем без config-файла (дефолты + env)
|
||||||
a, err := New("", "dev", "")
|
a, err := New("", "dev", "", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() err = %v", err)
|
t.Fatalf("New() err = %v", err)
|
||||||
}
|
}
|
||||||
|
defer a.Store.Close()
|
||||||
if a.Store == nil {
|
if a.Store == nil {
|
||||||
t.Fatal("Store не создан")
|
t.Fatal("Store не создан")
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,7 @@ func TestNew_MissingToken(t *testing.T) {
|
|||||||
t.Setenv("TG_CHAT_ID", "12345")
|
t.Setenv("TG_CHAT_ID", "12345")
|
||||||
t.Setenv("RATATOSKR_DB", dbPath)
|
t.Setenv("RATATOSKR_DB", dbPath)
|
||||||
|
|
||||||
_, err := New("", "dev", "")
|
_, err := New("", "dev", "", true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for missing token")
|
t.Fatal("expected error for missing token")
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,7 @@ func TestNew_BadDB(t *testing.T) {
|
|||||||
t.Setenv("TG_CHAT_ID", "12345")
|
t.Setenv("TG_CHAT_ID", "12345")
|
||||||
t.Setenv("RATATOSKR_DB", dbPath)
|
t.Setenv("RATATOSKR_DB", dbPath)
|
||||||
|
|
||||||
_, err := New("", "dev", "")
|
_, err := New("", "dev", "", true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid db path")
|
t.Fatal("expected error for invalid db path")
|
||||||
}
|
}
|
||||||
@@ -83,10 +84,11 @@ func TestNew_RunCtxCancel(t *testing.T) {
|
|||||||
t.Setenv("TG_CHAT_ID", "12345")
|
t.Setenv("TG_CHAT_ID", "12345")
|
||||||
t.Setenv("RATATOSKR_DB", dbPath)
|
t.Setenv("RATATOSKR_DB", dbPath)
|
||||||
|
|
||||||
a, err := New("", "dev", "")
|
a, err := New("", "dev", "", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() err = %v", err)
|
t.Fatalf("New() err = %v", err)
|
||||||
}
|
}
|
||||||
|
defer a.Store.Close()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel() // сразу отменяем
|
cancel() // сразу отменяем
|
||||||
@@ -120,15 +122,15 @@ func TestNew_WorktreeCreated(t *testing.T) {
|
|||||||
" token: \"test:token\"",
|
" token: \"test:token\"",
|
||||||
" chat_id: \"12345\"",
|
" chat_id: \"12345\"",
|
||||||
"paths:",
|
"paths:",
|
||||||
" db: \"" + filepath.Join(tmp, "test.db") + "\"",
|
" db: '" + filepath.Join(tmp, "test.db") + "'",
|
||||||
" worktree: \"" + wt + "\"",
|
" worktree: '" + wt + "'",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a, err := New(configPath, "dev", "")
|
a, err := New(configPath, "dev", "", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() err = %v", err)
|
t.Fatalf("New() err = %v", err)
|
||||||
}
|
}
|
||||||
@@ -165,7 +167,7 @@ func TestNew_UpdateWiring(t *testing.T) {
|
|||||||
" base_url: \"https://hub.example.com\"",
|
" base_url: \"https://hub.example.com\"",
|
||||||
" token: \"cfg-update-token\"",
|
" token: \"cfg-update-token\"",
|
||||||
"paths:",
|
"paths:",
|
||||||
" db: \"" + dbPath + "\"",
|
" db: '" + dbPath + "'",
|
||||||
"", // пустая строка в конце
|
"", // пустая строка в конце
|
||||||
}, "\n")
|
}, "\n")
|
||||||
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
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
|
// 1) base_url из update-блока, а НЕ git
|
||||||
a, err := New(configPath, "dev", "")
|
a, err := New(configPath, "dev", "", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() err = %v", err)
|
t.Fatalf("New() err = %v", err)
|
||||||
}
|
}
|
||||||
|
defer a.Store.Close()
|
||||||
if a.Updater == nil {
|
if a.Updater == nil {
|
||||||
t.Fatal("Updater не создан")
|
t.Fatal("Updater не создан")
|
||||||
}
|
}
|
||||||
@@ -193,10 +196,11 @@ func TestNew_UpdateWiring(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) вшитый updateToken перекрывает конфиг
|
// 2) вшитый updateToken перекрывает конфиг
|
||||||
a2, err := New(configPath, "dev", "embedded-update-token")
|
a2, err := New(configPath, "dev", "embedded-update-token", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() err = %v", err)
|
t.Fatalf("New() err = %v", err)
|
||||||
}
|
}
|
||||||
|
defer a2.Store.Close()
|
||||||
if a2.Updater.Token != "embedded-update-token" {
|
if a2.Updater.Token != "embedded-update-token" {
|
||||||
t.Errorf("Token = %q, want embedded-update-token (вшитый приоритетнее)", a2.Updater.Token)
|
t.Errorf("Token = %q, want embedded-update-token (вшитый приоритетнее)", a2.Updater.Token)
|
||||||
}
|
}
|
||||||
|
|||||||
899
internal/app/e2e_test.go
Normal file
899
internal/app/e2e_test.go
Normal file
@@ -0,0 +1,899 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
// Интеграционный (сквозной) тест «всё приложение от постановки задачи».
|
||||||
|
//
|
||||||
|
// Закрывает оба слоя конвейера одним прогоном, как в проде:
|
||||||
|
//
|
||||||
|
// handleIncoming (/start) → Core.ProcessTurn → Analyst (opencode) → задача ready
|
||||||
|
// Worker.runTask: dev → reviewer → настоящий git push → success
|
||||||
|
//
|
||||||
|
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
|
||||||
|
// а 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"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/analyst"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/core"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// вердикты фейкового агента по имени.
|
||||||
|
var e2eAgentVerdicts = map[string]string{
|
||||||
|
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
|
||||||
|
"dev": `done`,
|
||||||
|
"reviewer": `{"passed":true,"comments":[]}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
// но с фейк-сервером opencode (e2eFakeAPI), зарегистрированным в пуле.
|
||||||
|
// Возвращает App, каталог worktree и fake-канал (для проверки исходящих).
|
||||||
|
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
worktree := filepath.Join(dir, "worktrees")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := storage.Open(ctx, filepath.Join(dir, "e2e.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("storage.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
|
||||||
|
fakeURL := e2eFakeAPI(t)
|
||||||
|
pool := opencode.NewPool(worktree)
|
||||||
|
pool.RegisterExternal(worktree, fakeURL) // worktree обслуживается фейком
|
||||||
|
runner := &opencode.Runner{
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Router с fake-каналом: ловим исходящие replies для проверки.
|
||||||
|
fake := &e2eChannel{sent: make([]chat.Message, 0)}
|
||||||
|
a := &App{
|
||||||
|
Store: store,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &worker.Worker{
|
||||||
|
Store: store,
|
||||||
|
Runner: runner,
|
||||||
|
Worktree: worktree,
|
||||||
|
Agent: "dev",
|
||||||
|
Interval: 30 * time.Millisecond,
|
||||||
|
MaxJobs: 1,
|
||||||
|
Live: opencode.NewLiveRegistry(),
|
||||||
|
Notify: a, // авто-уведомления владельцу через Router (как в app.New)
|
||||||
|
// GitToken зададим пустым: origin в seed-репо локальный (file path),
|
||||||
|
// http.extraHeader не нужен для локального пуша.
|
||||||
|
}
|
||||||
|
a.Worker = w
|
||||||
|
return a, worktree, fake
|
||||||
|
}
|
||||||
|
|
||||||
|
// e2eChannel — минимальный fake-канал для перехвата исходящих
|
||||||
|
// и доставки входящих через роутер (как реальный канал).
|
||||||
|
type e2eChannel struct {
|
||||||
|
onMsg chat.Handler
|
||||||
|
sent []chat.Message
|
||||||
|
router *chat.Router
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *e2eChannel) Run(_ context.Context) error { return nil }
|
||||||
|
func (c *e2eChannel) OnMessage(h chat.Handler) {
|
||||||
|
c.onMsg = h
|
||||||
|
}
|
||||||
|
func (c *e2eChannel) Send(_ context.Context, _ chat.Address, m chat.Message) error {
|
||||||
|
c.sent = append(c.sent, m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c *e2eChannel) Ask(_ context.Context, _ chat.Address, m chat.Message) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c *e2eChannel) Close() error { return nil }
|
||||||
|
|
||||||
|
// deliver отправляет входящее сообщение через роутер: ставит маршрут
|
||||||
|
// пользователя и вызывает app.handleIncoming (как в проде). Так как роутер
|
||||||
|
// обрабатывает входящие асинхронно (процессор-горутина), deliver ждёт, пока
|
||||||
|
// обработка события завершится, — иначе тесты (сразу читающие состояние БД)
|
||||||
|
// гоняются с обработчиком.
|
||||||
|
func (c *e2eChannel) deliver(uid chat.UserID, text string) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedFakeRepo создаёт реальный git-репозиторий для worktree/<repo>:
|
||||||
|
// bare-origin + клон с начальным коммитом на main, чтобы воркер мог
|
||||||
|
// выполнять fetch origin, создавать ветку и пушить. (Дубликат из worker_test,
|
||||||
|
// вынесен сюда, т.к. интеграционный тест живёт в пакете app, а не worker.)
|
||||||
|
func (a *App) seedFakeRepo(t *testing.T, worktree, repo string) {
|
||||||
|
t.Helper()
|
||||||
|
gitRun := func(dir string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
base := filepath.Join(worktree, repo)
|
||||||
|
if err := os.MkdirAll(base, 0o755); err != nil {
|
||||||
|
t.Fatalf("seed repo %s: %v", repo, err)
|
||||||
|
}
|
||||||
|
origin := filepath.Join(worktree, repo+"-origin.git")
|
||||||
|
gitRun(worktree, "init", "--bare", origin)
|
||||||
|
gitRun(base, "init")
|
||||||
|
gitRun(base, "remote", "add", "origin", origin)
|
||||||
|
if err := os.WriteFile(filepath.Join(base, "seed.txt"), []byte("seed\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write seed: %v", err)
|
||||||
|
}
|
||||||
|
gitRun(base, "add", "seed.txt")
|
||||||
|
gitRun(base, "commit", "-m", "seed")
|
||||||
|
gitRun(base, "branch", "-M", "main")
|
||||||
|
gitRun(base, "push", "-u", "origin", "main")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestE2EWholeAppFromTaskSetup — положительный сквозной путь «всё приложение»:
|
||||||
|
// постановка задачи через аналитика (до ready), затем воркер (dev→reviewer→push)
|
||||||
|
// до success, и проверка, что feature-ветка реально запушена в origin.
|
||||||
|
func TestE2EWholeAppFromTaskSetup(t *testing.T) {
|
||||||
|
a, worktree, fake := e2eAssemble(t)
|
||||||
|
a.seedFakeRepo(t, worktree, "calc")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
uid := chat.UserID("u1")
|
||||||
|
|
||||||
|
// --- 1. Постановка: /start → задача collecting ---
|
||||||
|
fake.deliver(uid, "/start")
|
||||||
|
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task after /start: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != storage.StatusCollecting {
|
||||||
|
t.Errorf("status после /start = %q, want collecting", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Постановка: ответ пользователя → аналитик (opencode) → ready ---
|
||||||
|
// Фейк-analyst возвращает propose с repo=calc → задача должна стать ready.
|
||||||
|
fake.deliver(uid, "Сделай калькулятор в calc")
|
||||||
|
task, err = a.Store.GetTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != storage.StatusReady {
|
||||||
|
t.Fatalf("status после analyst = %q, want ready", task.Status)
|
||||||
|
}
|
||||||
|
if len(task.EffectiveRepos()) == 0 {
|
||||||
|
t.Errorf("repos пусто, want [calc]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Согласие «создавай» → задача одобрена (approved) ---
|
||||||
|
fake.deliver(uid, "создавай")
|
||||||
|
task, err = a.Store.GetTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != storage.StatusApproved {
|
||||||
|
t.Errorf("status после создавай = %q, want approved", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Исполнение: воркер (poll) dev → reviewer → push ---
|
||||||
|
// Запускаем реальный poll-диспетчер и ждём, пока задача доедет до success.
|
||||||
|
wkCtx, wkCancel := context.WithCancel(ctx)
|
||||||
|
defer wkCancel()
|
||||||
|
a.Worker.Start(wkCtx)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
var last storage.Status
|
||||||
|
for {
|
||||||
|
task, err = a.Store.GetTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
last = task.Status
|
||||||
|
if task.Status == storage.StatusSuccess || task.Status == storage.StatusFailed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("таймаут ожидания success, последний статус %q", last)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err = a.Store.GetTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != storage.StatusSuccess {
|
||||||
|
t.Fatalf("status после воркера = %q, want success", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ревью вызывается ровно один раз (passed сразу) — 2 трассы: dev + reviewer
|
||||||
|
traces, err := a.Store.GetTraces(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get traces: %v", err)
|
||||||
|
}
|
||||||
|
if len(traces) != 2 {
|
||||||
|
t.Fatalf("traces = %d, want 2 (dev + reviewer)", len(traces))
|
||||||
|
}
|
||||||
|
if traces[0].Agent != "dev" || traces[0].Status != storage.TraceSuccess {
|
||||||
|
t.Errorf("trace[0] = %s/%s, want dev/success", traces[0].Agent, traces[0].Status)
|
||||||
|
}
|
||||||
|
if traces[1].Agent != "reviewer" || traces[1].Status != storage.TraceSuccess {
|
||||||
|
t.Errorf("trace[1] = %s/%s, want reviewer/success", traces[1].Agent, traces[1].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. Проверка настоящего push: feature-ветка есть в origin ---
|
||||||
|
branch := "feat/" + task.TaskTag
|
||||||
|
out, err := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
|
||||||
|
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ls-remote origin: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "refs/heads/"+branch) {
|
||||||
|
t.Errorf("feature-ветка %q не найдена в origin:\n%s", branch, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. Бот подтвердил через канал (хотя бы одно исходящее было) ---
|
||||||
|
if len(fake.sent) == 0 {
|
||||||
|
t.Error("нет ни одного исходящего сообщения через Router")
|
||||||
|
}
|
||||||
|
t.Logf("E2E OK: задача #%d %s, ветка %s, traces=%d", task.ID, task.Status, branch, len(traces))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestE2ERetryFromStates — положительный сценарий «перезапуск задачи»:
|
||||||
|
// задача перезапускается /retry N из различных стартовых состояний и после
|
||||||
|
// повторного цикла (аналитик → ready → создавай → воркер) снова доходит до
|
||||||
|
// success с повторным реальным push feature-ветки.
|
||||||
|
//
|
||||||
|
// Матрица стартовых состояний:
|
||||||
|
// collecting / ready / cancelled — реальными командами через канал;
|
||||||
|
// success — реальным полным циклом воркера;
|
||||||
|
// failed / timeout — фикстурой через Store (валидной цепочкой переходов),
|
||||||
|
// т.к. механика их достижения в воркере уже покрыта worker_test.go, а
|
||||||
|
// timeout вообще нельзя сэмулировать без реального зависания (RC=-1
|
||||||
|
// ставит только kill по таймауту).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// e2eChainToState приводит задачу к заданному статусу валидной цепочкой
|
||||||
|
// переходов через Store.UpdateTask (фикстура для failed/timeout).
|
||||||
|
func e2eChainToState(t *testing.T, store *storage.Storage, id int64, target storage.Status) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
// валидные цепочки draft → ... → target (проверяются UpdateTask'ом)
|
||||||
|
var chain []storage.Status
|
||||||
|
switch target {
|
||||||
|
case storage.StatusFailed:
|
||||||
|
chain = []storage.Status{storage.StatusCollecting, storage.StatusReady, storage.StatusApproved, storage.StatusRunning, storage.StatusFailed}
|
||||||
|
case storage.StatusTimeout:
|
||||||
|
chain = []storage.Status{storage.StatusCollecting, storage.StatusReady, storage.StatusApproved, storage.StatusRunning, storage.StatusTimeout}
|
||||||
|
default:
|
||||||
|
t.Fatalf("e2eChainToState: неподдерживаемый target %q", target)
|
||||||
|
}
|
||||||
|
for _, s := range chain {
|
||||||
|
tk, err := store.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eChainToState: get task %d: %v", id, err)
|
||||||
|
}
|
||||||
|
tk.Status = s
|
||||||
|
if err := store.UpdateTask(ctx, tk); err != nil {
|
||||||
|
t.Fatalf("e2eChainToState: %s → %s: %v", tk.Status, s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// e2eWaitWorker запускает poll-диспетчер воркера и ждёт, пока задача дойдёт
|
||||||
|
// до success или failed (лимит 30s). Возвращает итоговый статус.
|
||||||
|
func e2eWaitWorker(t *testing.T, a *App, id int64) storage.Status {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
wkCtx, wkCancel := context.WithCancel(ctx)
|
||||||
|
defer wkCancel()
|
||||||
|
a.Worker.Start(wkCtx)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
var last storage.Status
|
||||||
|
for {
|
||||||
|
tk, err := a.Store.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eWaitWorker: get task %d: %v", id, err)
|
||||||
|
}
|
||||||
|
last = tk.Status
|
||||||
|
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
|
||||||
|
return tk.Status
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("e2eWaitWorker: таймаут, последний статус %q", last)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// e2eRetryCommon — общий хвост после /retry N для любого стартового состояния.
|
||||||
|
// Проверяет: collecting → аналитик → ready → создавай → воркер → success,
|
||||||
|
// повторный push ветки, и что тег задачи переиспользован (не сменился).
|
||||||
|
func e2eRetryCommon(t *testing.T, a *App, fake *e2eChannel, worktree, uid string, taskID int64) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// тег до retry — должен остаться прежним после повторного цикла
|
||||||
|
before, err := a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryCommon: get task %d: %v", taskID, err)
|
||||||
|
}
|
||||||
|
origTag := before.TaskTag
|
||||||
|
baseTraces, err := a.Store.GetTraces(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryCommon: get traces (before): %v", err)
|
||||||
|
}
|
||||||
|
baseN := len(baseTraces)
|
||||||
|
|
||||||
|
// 1. /retry N → collecting, история очищена
|
||||||
|
fake.deliver(chat.UserID(uid), "/retry "+fmt.Sprintf("%d", taskID))
|
||||||
|
tk, err := a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryCommon: get task after /retry: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status != storage.StatusCollecting {
|
||||||
|
t.Errorf("retry: status = %q, want collecting", tk.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. новый текст → аналитик → ready (repos не пуст)
|
||||||
|
fake.deliver(chat.UserID(uid), "Переделай: добавь деление")
|
||||||
|
tk, err = a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryCommon: get task after analyst: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status != storage.StatusReady {
|
||||||
|
t.Fatalf("retry: status после аналитика = %q, want ready", tk.Status)
|
||||||
|
}
|
||||||
|
if len(tk.EffectiveRepos()) == 0 {
|
||||||
|
t.Errorf("retry: repos пусто, want [calc]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. «создавай» → approved (одобрено, воркер заберёт)
|
||||||
|
fake.deliver(chat.UserID(uid), "создавай")
|
||||||
|
tk, err = a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryCommon: get task after создавай: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status != storage.StatusApproved {
|
||||||
|
t.Errorf("retry: status после создавай = %q, want approved", tk.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. воркер → success
|
||||||
|
if got := e2eWaitWorker(t, a, taskID); got != storage.StatusSuccess {
|
||||||
|
t.Fatalf("retry: итог = %q, want success", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. проверки после повторного цикла
|
||||||
|
tk, err = a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryCommon: get task final: %v", err)
|
||||||
|
}
|
||||||
|
if tk.TaskTag != origTag {
|
||||||
|
t.Errorf("retry: task_tag сменился %q → %q, want неизменный", origTag, tk.TaskTag)
|
||||||
|
}
|
||||||
|
branch := "feat/" + tk.TaskTag
|
||||||
|
out, err := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
|
||||||
|
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry: ls-remote origin: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "refs/heads/"+branch) {
|
||||||
|
t.Errorf("retry: feature-ветка %q не найдена в origin после повторного push:\n%s", branch, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. появились ровно 2 новые трассы (dev + reviewer), обе успешные
|
||||||
|
traces, err := a.Store.GetTraces(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryCommon: get traces (after): %v", err)
|
||||||
|
}
|
||||||
|
if len(traces) != baseN+2 {
|
||||||
|
t.Fatalf("retry: трасс = %d, want %d (base %d + dev + reviewer)", len(traces), baseN+2, baseN)
|
||||||
|
}
|
||||||
|
if traces[baseN].Agent != "dev" || traces[baseN].Status != storage.TraceSuccess {
|
||||||
|
t.Errorf("retry: trace[%d] = %s/%s, want dev/success", baseN, traces[baseN].Agent, traces[baseN].Status)
|
||||||
|
}
|
||||||
|
if traces[baseN+1].Agent != "reviewer" || traces[baseN+1].Status != storage.TraceSuccess {
|
||||||
|
t.Errorf("retry: trace[%d] = %s/%s, want reviewer/success", baseN+1, traces[baseN+1].Agent, traces[baseN+1].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. бот ответил
|
||||||
|
if len(fake.sent) == 0 {
|
||||||
|
t.Error("retry: нет ни одного исходящего сообщения через Router")
|
||||||
|
}
|
||||||
|
t.Logf("RETRY OK: задача #%d %s (из %s), ветка %s, new traces=%d", taskID, tk.Status, before.Status, branch, len(traces)-baseN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// e2eSetupState приводит задачу к стартовому состоянию S и возвращает её ID.
|
||||||
|
// Для состояний, достижимых командами — реальный путь через канал; для
|
||||||
|
// failed/timeout — фикстура через Store.
|
||||||
|
func e2eSetupState(t *testing.T, a *App, fake *e2eChannel, worktree, uid string, state storage.Status) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case storage.StatusCollecting:
|
||||||
|
fake.deliver(chat.UserID(uid), "/start")
|
||||||
|
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup collecting: %v", err)
|
||||||
|
}
|
||||||
|
return tk.ID
|
||||||
|
|
||||||
|
case storage.StatusReady:
|
||||||
|
fake.deliver(chat.UserID(uid), "/start")
|
||||||
|
fake.deliver(chat.UserID(uid), "Сделай калькулятор в calc")
|
||||||
|
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup ready: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status != storage.StatusReady {
|
||||||
|
t.Fatalf("setup ready: status = %q, want ready", tk.Status)
|
||||||
|
}
|
||||||
|
return tk.ID
|
||||||
|
|
||||||
|
case storage.StatusCancelled:
|
||||||
|
fake.deliver(chat.UserID(uid), "/start")
|
||||||
|
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup cancelled: get active: %v", err)
|
||||||
|
}
|
||||||
|
fake.deliver(chat.UserID(uid), "/cancel")
|
||||||
|
tk, err = a.Store.GetTask(ctx, tk.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup cancelled: get task: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status != storage.StatusCancelled {
|
||||||
|
t.Fatalf("setup cancelled: status = %q, want cancelled", tk.Status)
|
||||||
|
}
|
||||||
|
return tk.ID
|
||||||
|
|
||||||
|
case storage.StatusSuccess:
|
||||||
|
// реальный полный цикл до success
|
||||||
|
fake.deliver(chat.UserID(uid), "/start")
|
||||||
|
fake.deliver(chat.UserID(uid), "Сделай калькулятор в calc")
|
||||||
|
fake.deliver(chat.UserID(uid), "создавай")
|
||||||
|
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup success: get active: %v", err)
|
||||||
|
}
|
||||||
|
if got := e2eWaitWorker(t, a, tk.ID); got != storage.StatusSuccess {
|
||||||
|
t.Fatalf("setup success: итог = %q, want success", got)
|
||||||
|
}
|
||||||
|
return tk.ID
|
||||||
|
|
||||||
|
case storage.StatusFailed, storage.StatusTimeout:
|
||||||
|
// фикстура: создаём задачу и прогоняем валидную цепочку переходов
|
||||||
|
id, err := a.Store.CreateTask(ctx, &storage.Task{ChatID: uid})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup %s: create task: %v", state, err)
|
||||||
|
}
|
||||||
|
e2eChainToState(t, a.Store, id, state)
|
||||||
|
return id
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Fatalf("setup: неподдерживаемое состояние %q", state)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestE2ERetryFromStates — перезапуск задачи /retry N из различных состояний.
|
||||||
|
//
|
||||||
|
// Позитивный блок (задача реально перезапускается и доходит до success):
|
||||||
|
// collecting, ready, failed, timeout.
|
||||||
|
// Негативный блок (завершённые состояния нельзя перезапускать — только /start):
|
||||||
|
// success, cancelled → /retry отклоняется понятным сообщением, статус не меняется.
|
||||||
|
func TestE2ERetryFromStates(t *testing.T) {
|
||||||
|
a, worktree, fake := e2eAssemble(t)
|
||||||
|
a.seedFakeRepo(t, worktree, "calc")
|
||||||
|
|
||||||
|
// --- Позитивный блок: перезапуск и повторный успешный цикл ---
|
||||||
|
positive := []storage.Status{
|
||||||
|
storage.StatusCollecting,
|
||||||
|
storage.StatusReady,
|
||||||
|
storage.StatusFailed,
|
||||||
|
storage.StatusTimeout,
|
||||||
|
}
|
||||||
|
for _, st := range positive {
|
||||||
|
st := st
|
||||||
|
t.Run("positive/"+string(st), func(t *testing.T) {
|
||||||
|
uid := fmt.Sprintf("retry-pos-%s", st)
|
||||||
|
id := e2eSetupState(t, a, fake, worktree, uid, st)
|
||||||
|
e2eRetryCommon(t, a, fake, worktree, uid, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Негативный блок: завершённые состояния перезапуску не подлежат ---
|
||||||
|
terminal := []storage.Status{
|
||||||
|
storage.StatusSuccess,
|
||||||
|
storage.StatusCancelled,
|
||||||
|
}
|
||||||
|
for _, st := range terminal {
|
||||||
|
st := st
|
||||||
|
t.Run("negative/"+string(st), func(t *testing.T) {
|
||||||
|
uid := fmt.Sprintf("retry-neg-%s", st)
|
||||||
|
id := e2eSetupState(t, a, fake, worktree, uid, st)
|
||||||
|
e2eRetryRejected(t, a, fake, uid, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// e2eRetryRejected проверяет, что /retry на завершённую задачу НЕ перезапускает:
|
||||||
|
// статус остаётся терминальным, а пользователю уходит понятное сообщение.
|
||||||
|
func e2eRetryRejected(t *testing.T, a *App, fake *e2eChannel, uid string, taskID int64) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before, err := a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryRejected: get task %d: %v", taskID, err)
|
||||||
|
}
|
||||||
|
if !storage.IsTerminal(before.Status) {
|
||||||
|
t.Fatalf("e2eRetryRejected: предпосылка — статус %q должен быть терминальным", before.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// сбрасываем перехваченные исходящие, чтобы ловить только ответ на /retry
|
||||||
|
fake.sent = nil
|
||||||
|
fake.deliver(chat.UserID(uid), "/retry "+fmt.Sprintf("%d", taskID))
|
||||||
|
|
||||||
|
after, err := a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("e2eRetryRejected: get task after /retry: %v", err)
|
||||||
|
}
|
||||||
|
if after.Status != before.Status {
|
||||||
|
t.Errorf("retry(neg): статус изменился %q → %q, want без изменений", before.Status, after.Status)
|
||||||
|
}
|
||||||
|
if after.TaskTag != before.TaskTag {
|
||||||
|
t.Errorf("retry(neg): task_tag сменился, want неизменный")
|
||||||
|
}
|
||||||
|
|
||||||
|
// понятное сообщение пользователю: говорим про невозможность перезапуска
|
||||||
|
found := false
|
||||||
|
for _, m := range fake.sent {
|
||||||
|
if strings.Contains(m.Text, "нельзя перезапустить") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("retry(neg): нет понятного ответа «нельзя перезапустить», отправлено: %d сообщений", len(fake.sent))
|
||||||
|
}
|
||||||
|
t.Logf("RETRY-REJECTED OK: задача #%d осталась %q", taskID, after.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestE2EWorkerDoesNotTakeUnconfirmed — регрессия бага, когда воркер брал
|
||||||
|
// задачу на выполнение ещё до «создавай» (по статусу ready, без одобрения).
|
||||||
|
// Проверяет: пока задача в ready, воркер её НЕ трогает; она уходит в работу
|
||||||
|
// только после «создавай» → approved.
|
||||||
|
func TestE2EWorkerDoesNotTakeUnconfirmed(t *testing.T) {
|
||||||
|
a, worktree, fake := e2eAssemble(t)
|
||||||
|
a.seedFakeRepo(t, worktree, "calc")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
uid := chat.UserID("unconf")
|
||||||
|
|
||||||
|
// --- 1. Постановка до ready, без «создавай» ---
|
||||||
|
fake.deliver(uid, "/start")
|
||||||
|
fake.deliver(uid, "Сделай калькулятор в calc")
|
||||||
|
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != storage.StatusReady {
|
||||||
|
t.Fatalf("status после analyst = %q, want ready (черновик готов, но НЕ одобрен)", task.Status)
|
||||||
|
}
|
||||||
|
beforeTraces, _ := a.Store.GetTraces(ctx, task.ID)
|
||||||
|
|
||||||
|
// --- 2. Запускаем воркер и даём ему время «промахнуться» ---
|
||||||
|
wkCtx, wkCancel := context.WithCancel(ctx)
|
||||||
|
defer wkCancel()
|
||||||
|
a.Worker.Start(wkCtx)
|
||||||
|
|
||||||
|
time.Sleep(600 * time.Millisecond) // несколько poll-итераций (Interval=30ms)
|
||||||
|
|
||||||
|
// --- 3. Проверяем, что воркер НЕ взял неодобренную задачу ---
|
||||||
|
after, err := a.Store.GetTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task after worker: %v", err)
|
||||||
|
}
|
||||||
|
if after.Status != storage.StatusReady {
|
||||||
|
t.Fatalf("БАГ: воркер взял неодобренную задачу — status = %q, want ready. Задача должна ждать «создавай».", after.Status)
|
||||||
|
}
|
||||||
|
afterTraces, _ := a.Store.GetTraces(ctx, task.ID)
|
||||||
|
if len(afterTraces) != len(beforeTraces) {
|
||||||
|
t.Errorf("БАГ: появились трассы без одобрения (было %d, стало %d)", len(beforeTraces), len(afterTraces))
|
||||||
|
}
|
||||||
|
// ветка не должна была создаться
|
||||||
|
branch := "feat/" + after.TaskTag
|
||||||
|
out, _ := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
|
||||||
|
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
|
||||||
|
if strings.Contains(string(out), "refs/heads/"+branch) {
|
||||||
|
t.Errorf("БАГ: feature-ветка %q уже в origin до одобрения", branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. «создавай» → approved, теперь воркер берёт и доезжает до success ---
|
||||||
|
fake.deliver(uid, "создавай")
|
||||||
|
tk, err := a.Store.GetTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task after создавай: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status != storage.StatusApproved {
|
||||||
|
t.Fatalf("status после создавай = %q, want approved", tk.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for {
|
||||||
|
tk, err = a.Store.GetTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("таймаут ожидания success, последний статус %q", tk.Status)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if tk.Status != storage.StatusSuccess {
|
||||||
|
t.Fatalf("status после воркера = %q, want success", tk.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ветка теперь должна быть в origin
|
||||||
|
out, err = exec.Command("git", "-C", filepath.Join(worktree, "calc"),
|
||||||
|
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ls-remote origin: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "refs/heads/"+branch) {
|
||||||
|
t.Errorf("feature-ветка %q не найдена в origin после одобрения:\n%s", branch, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("WORKER-CONSENT OK: в ready воркер не трогал, после «создавай» → success, ветка %s", branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestE2ENotificationsOnTransitions — авто-уведомления владельцу на каждый
|
||||||
|
// переход статуса задачи и отсутствие задвоения на approved.
|
||||||
|
//
|
||||||
|
// Проверяет сквозной поток сообщений через chat.Router:
|
||||||
|
// - ready → approved («создавай»): ровно ОДНО сообщение (ответ «одобрена»),
|
||||||
|
// отдельное уведомление "Задача #N: approved" НЕ дублируется;
|
||||||
|
// - со стороны воркера: уведомления running → dev→reviewer → success
|
||||||
|
// доходят владельцу через Router.Send.
|
||||||
|
func TestE2ENotificationsOnTransitions(t *testing.T) {
|
||||||
|
a, worktree, fake := e2eAssemble(t)
|
||||||
|
a.seedFakeRepo(t, worktree, "calc")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
uid := chat.UserID("u-notif")
|
||||||
|
|
||||||
|
// --- 1. постановка: /start → draft→collecting (приветствие) ---
|
||||||
|
fake.deliver(uid, "/start")
|
||||||
|
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task after /start: %v", err)
|
||||||
|
}
|
||||||
|
taskID := task.ID
|
||||||
|
|
||||||
|
// --- 2. текст → collecting→ready (сводка черновика) ---
|
||||||
|
fake.deliver(uid, "Сделай калькулятор в calc")
|
||||||
|
task, err = a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != storage.StatusReady {
|
||||||
|
t.Fatalf("status = %q, want ready", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. «создавай» → ready→approved: один ответ, без дубля-уведомления ---
|
||||||
|
fake.deliver(uid, "создавай")
|
||||||
|
task, err = a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task after создавай: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != storage.StatusApproved {
|
||||||
|
t.Fatalf("status = %q, want approved", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
approvedReplies := 0
|
||||||
|
var approvedText string
|
||||||
|
approvedNotifs := 0
|
||||||
|
for _, m := range fake.sent {
|
||||||
|
if strings.Contains(m.Text, "одобрена") {
|
||||||
|
approvedReplies++
|
||||||
|
approvedText = m.Text
|
||||||
|
}
|
||||||
|
if strings.Contains(m.Text, fmt.Sprintf("Задача #%d: approved", taskID)) {
|
||||||
|
approvedNotifs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if approvedReplies != 1 {
|
||||||
|
t.Errorf("сообщений об одобрении = %d, want ровно 1 (нет задвоения)", approvedReplies)
|
||||||
|
}
|
||||||
|
if approvedNotifs != 0 {
|
||||||
|
t.Errorf("отдельное уведомление 'Задача #%d: approved' продублировано (%d раз)", taskID, approvedNotifs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(approvedText, "одобрена") {
|
||||||
|
t.Errorf("ответ об одобрении = %q", approvedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. воркер: running → dev→reviewer → success через Router ---
|
||||||
|
wkCtx, wkCancel := context.WithCancel(ctx)
|
||||||
|
defer wkCancel()
|
||||||
|
a.Worker.Start(wkCtx)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for {
|
||||||
|
tk, err := a.Store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("таймаут ожидания success, последний статус %q", tk.Status)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// финальные статус-уведомления воркера в правильном порядке
|
||||||
|
wantOrder := []string{
|
||||||
|
fmt.Sprintf("Задача #%d: running", taskID),
|
||||||
|
fmt.Sprintf("Задача #%d: dev → reviewer (итерация 1)", taskID),
|
||||||
|
fmt.Sprintf("Задача #%d: success", taskID),
|
||||||
|
}
|
||||||
|
var gotOrder []string
|
||||||
|
for _, m := range fake.sent {
|
||||||
|
if strings.HasPrefix(m.Text, fmt.Sprintf("Задача #%d:", taskID)) {
|
||||||
|
gotOrder = append(gotOrder, m.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotOrder, wantOrder) {
|
||||||
|
t.Errorf("уведомления воркера = %#v, want %#v", gotOrder, wantOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("NOTIFICATIONS OK: задача #%d, сообщений одобрения=%d, уведомления=%v", taskID, approvedReplies, gotOrder)
|
||||||
|
}
|
||||||
15
internal/app/ui_cgo.go
Normal file
15
internal/app/ui_cgo.go
Normal 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
11
internal/app/ui_noui.go
Normal 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 }
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router — единый диспетчер входящих из всех каналов и маршрутизатор исходящих.
|
// Router — единый диспетчер входящих из всех каналов и маршрутизатор исходящих.
|
||||||
@@ -18,6 +20,16 @@ type Router struct {
|
|||||||
|
|
||||||
// Hook, вызываемый на каждое входящее событие (обычно → process_turn).
|
// Hook, вызываемый на каждое входящее событие (обычно → process_turn).
|
||||||
onUserMsg func(Incoming)
|
onUserMsg func(Incoming)
|
||||||
|
|
||||||
|
// Асинхронная обработка входящих: handleIncoming кладёт событие в канал,
|
||||||
|
// воркер-горутина последовательно вызывает onUserMsg. Благодаря этому
|
||||||
|
// long-poll цикл канала (Telegram) не блокируется на время долгого
|
||||||
|
// вызова аналитика и продолжает принимать новые сообщения.
|
||||||
|
incoming chan Incoming
|
||||||
|
|
||||||
|
// processed — число обработанных воркером событий (для синхронизации
|
||||||
|
// тестов с асинхронной очередью: WaitProcessed ждёт обработку события).
|
||||||
|
processed atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего.
|
// NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего.
|
||||||
@@ -25,12 +37,39 @@ func NewRouter(onUserMsg func(Incoming)) *Router {
|
|||||||
if onUserMsg == nil {
|
if onUserMsg == nil {
|
||||||
onUserMsg = func(Incoming) {}
|
onUserMsg = func(Incoming) {}
|
||||||
}
|
}
|
||||||
return &Router{
|
r := &Router{
|
||||||
sessions: map[UserID]any{},
|
sessions: map[UserID]any{},
|
||||||
routes: map[UserID]Route{},
|
routes: map[UserID]Route{},
|
||||||
pending: map[UserID]PendingQ{},
|
pending: map[UserID]PendingQ{},
|
||||||
onUserMsg: onUserMsg,
|
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 регистрирует канал и подключает его к обработчику входящих.
|
// Attach регистрирует канал и подключает его к обработчику входящих.
|
||||||
@@ -59,7 +98,10 @@ func (r *Router) handleIncoming(inc Incoming) {
|
|||||||
delete(r.pending, inc.UserID)
|
delete(r.pending, inc.UserID)
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
r.onUserMsg(inc)
|
|
||||||
|
// Асинхронная обработка: кладём событие в очередь воркера и сразу
|
||||||
|
// возвращаемся, не блокируя вызывающий long-poll цикл канала.
|
||||||
|
r.incoming <- inc
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send уведомляет пользователя через текущий маршрут. M1 (нет маршрута) — no-op,
|
// Send уведомляет пользователя через текущий маршрут. M1 (нет маршрута) — no-op,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package chat
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -13,13 +15,58 @@ const (
|
|||||||
tui Address = "tui://local"
|
tui Address = "tui://local"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeOnMsg — тест-колбэк, копящий входящие.
|
// fakeOnMsg — тест-колбэк, копящий входящие. Т.к. Router теперь обрабатывает
|
||||||
type fakeOnMsg struct{ got []Incoming }
|
// входящие асинхронно (воркер-горутина), доступ потокобезопасный, а ожидание
|
||||||
|
// нужного числа сообщений — через 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) {
|
func TestRouter_AttachAndIncoming(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
|
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
@@ -28,10 +75,10 @@ func TestRouter_AttachAndIncoming(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tgCh.emit(uidA, tg, "привет")
|
tgCh.emit(uidA, tg, "привет")
|
||||||
if len(cb.got) != 1 {
|
if !cb.wait(1) {
|
||||||
t.Fatalf("handler got %d, want 1", len(cb.got))
|
t.Fatal("handler не получил входящее за таймаут")
|
||||||
}
|
}
|
||||||
got := cb.got[0]
|
got := cb.get(0)
|
||||||
if got.UserID != uidA || got.Address != tg || got.Msg.Text != "привет" {
|
if got.UserID != uidA || got.Address != tg || got.Msg.Text != "привет" {
|
||||||
t.Errorf("incoming = %+v", got)
|
t.Errorf("incoming = %+v", got)
|
||||||
}
|
}
|
||||||
@@ -53,12 +100,15 @@ func TestRouter_Send_NoRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
|
func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
|
|
||||||
_ = r.Attach(tgCh)
|
_ = r.Attach(tgCh)
|
||||||
tgCh.emit(uidA, tg, "hi") // устанавливает маршрут
|
tgCh.emit(uidA, tg, "hi") // устанавливает маршрут
|
||||||
|
if !cb.wait(1) {
|
||||||
|
t.Fatal("маршрут не установился за таймаут")
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.Send(context.Background(), uidA, Message{Text: "отв"}); err != nil {
|
if err := r.Send(context.Background(), uidA, Message{Text: "отв"}); err != nil {
|
||||||
t.Fatalf("Send: %v", err)
|
t.Fatalf("Send: %v", err)
|
||||||
@@ -72,7 +122,7 @@ func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
tuiCh := newFakeChannel(tui)
|
tuiCh := newFakeChannel(tui)
|
||||||
@@ -83,6 +133,9 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
|||||||
tgCh.emit(uidA, tg, "hi")
|
tgCh.emit(uidA, tg, "hi")
|
||||||
// продолжил в GUI
|
// продолжил в GUI
|
||||||
tuiCh.emit(uidA, tui, "продолжаю тут")
|
tuiCh.emit(uidA, tui, "продолжаю тут")
|
||||||
|
if !cb.wait(2) {
|
||||||
|
t.Fatal("входящие не обработаны за таймаут")
|
||||||
|
}
|
||||||
if tgCh.sentCount() != 0 || tuiCh.sentCount() != 0 {
|
if tgCh.sentCount() != 0 || tuiCh.sentCount() != 0 {
|
||||||
t.Fatal("до Send ничего не шлём")
|
t.Fatal("до Send ничего не шлём")
|
||||||
}
|
}
|
||||||
@@ -98,11 +151,14 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
|
func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
_ = r.Attach(tgCh)
|
_ = r.Attach(tgCh)
|
||||||
tgCh.emit(uidA, tg, "hi")
|
tgCh.emit(uidA, tg, "hi")
|
||||||
|
if !cb.wait(1) {
|
||||||
|
t.Fatal("первое входящее не обработано")
|
||||||
|
}
|
||||||
|
|
||||||
prompt := Message{Text: "Как зовут?", Options: []Option{{ID: "a", Label: "Анна"}}}
|
prompt := Message{Text: "Как зовут?", Options: []Option{{ID: "a", Label: "Анна"}}}
|
||||||
if err := r.Ask(context.Background(), uidA, prompt); err != nil {
|
if err := r.Ask(context.Background(), uidA, prompt); err != nil {
|
||||||
@@ -118,13 +174,16 @@ func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
|
|||||||
|
|
||||||
// ответ с того же адреса потребляет pending
|
// ответ с того же адреса потребляет pending
|
||||||
tgCh.emit(uidA, tg, "Анна")
|
tgCh.emit(uidA, tg, "Анна")
|
||||||
|
if !cb.wait(2) {
|
||||||
|
t.Fatal("ответ не обработан")
|
||||||
|
}
|
||||||
if _, ok := r.Pending(uidA); ok {
|
if _, ok := r.Pending(uidA); ok {
|
||||||
t.Fatal("pending должен быть закрыт после ответа")
|
t.Fatal("pending должен быть закрыт после ответа")
|
||||||
}
|
}
|
||||||
if len(cb.got) != 2 {
|
if cb.count() != 2 {
|
||||||
t.Fatalf("handler got %d, want 2 (hi + ответ)", len(cb.got))
|
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 вопроса")
|
t.Error("ответ должен нести QuestionID вопроса")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +196,7 @@ func TestRouter_Ask_NoRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) {
|
func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
tuiCh := newFakeChannel(tui)
|
tuiCh := newFakeChannel(tui)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
"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 {
|
func (ch *Channel) sendMsg(chatID, text string) error {
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"text": text[:min(len(text), 4000)],
|
"text": truncateUTF8(text, 4000),
|
||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
})
|
})
|
||||||
url := fmt.Sprintf(ch.apiURL+"sendMessage", ch.token)
|
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.
|
// formatOutgoing собирает Message в HTML-строку: текст + нумерованные Options.
|
||||||
func formatOutgoing(m chat.Message) string {
|
func formatOutgoing(m chat.Message) string {
|
||||||
if len(m.Options) == 0 {
|
if len(m.Options) == 0 {
|
||||||
return m.Text
|
return escapeHTML(m.Text)
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
buf.WriteString(m.Text)
|
buf.WriteString(escapeHTML(m.Text))
|
||||||
buf.WriteString("\n\n")
|
buf.WriteString("\n\n")
|
||||||
for i, opt := range m.Options {
|
for i, opt := range m.Options {
|
||||||
buf.WriteString(fmt.Sprintf("<b>%d.</b> %s\n", i+1, escapeHTML(opt.Label)))
|
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()
|
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 ----
|
// ---- Telegram API types ----
|
||||||
|
|
||||||
type tgResponse struct {
|
type tgResponse struct {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
"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 < 3 & 4 > 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<b") || !strings.Contains(got, "l&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) {
|
func TestIncoming(t *testing.T) {
|
||||||
f := newFakeTG(t)
|
f := newFakeTG(t)
|
||||||
ch := New("TOKEN", time.Second)
|
ch := New("TOKEN", time.Second)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -76,11 +77,75 @@ telegram:
|
|||||||
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
|
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
|
||||||
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
|
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" {
|
if cfg.Paths.Worktree != "./worktrees" {
|
||||||
t.Errorf("worktree = %q, want ./worktrees", cfg.Paths.Worktree)
|
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) {
|
func TestLoad_OpenCodeConfigDir(t *testing.T) {
|
||||||
t.Setenv("TG_TOKEN", "tok")
|
t.Setenv("TG_TOKEN", "tok")
|
||||||
t.Setenv("TG_CHAT_ID", "42")
|
t.Setenv("TG_CHAT_ID", "42")
|
||||||
@@ -186,14 +251,17 @@ func TestResolveExePaths_AbsoluteKept(t *testing.T) {
|
|||||||
t.Setenv("TG_TOKEN", "tok")
|
t.Setenv("TG_TOKEN", "tok")
|
||||||
t.Setenv("TG_CHAT_ID", "42")
|
t.Setenv("TG_CHAT_ID", "42")
|
||||||
|
|
||||||
absDB := filepath.Join(string(filepath.Separator), "data", "ratatoskr.db") // абсолютный для текущей ОС
|
// абсолютные пути «для текущей ОС»: на Windows слэш-относительный путь
|
||||||
absWt := filepath.Join(string(filepath.Separator), "worktrees")
|
// (\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:
|
yaml := `telegram:
|
||||||
username: "${TG_TOKEN}"
|
username: "${TG_TOKEN}"
|
||||||
chat_id: "${TG_CHAT_ID}"
|
chat_id: "${TG_CHAT_ID}"
|
||||||
paths:
|
paths:
|
||||||
db: "` + absDB + `"
|
db: '` + absDB + `'
|
||||||
worktree: "` + absWt + `"
|
worktree: '` + absWt + `'
|
||||||
`
|
`
|
||||||
cfg, err := Load(writeCfg(t, yaml))
|
cfg, err := Load(writeCfg(t, yaml))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -133,6 +134,18 @@ func applyDefaults(cfg *Config) {
|
|||||||
if fv.Kind() == reflect.String {
|
if fv.Kind() == reflect.String {
|
||||||
fv.SetString(meta.defaultVal)
|
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 {
|
if fv.Kind() == reflect.String {
|
||||||
fv.SetString(envVal)
|
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))
|
fn(fv, collectMeta(f))
|
||||||
} else if fv.Type() == durType {
|
} else if fv.Type() == durType {
|
||||||
fn(fv, collectMeta(f))
|
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 {
|
} else if fv.Kind() == reflect.Struct {
|
||||||
walk(fv, fn)
|
walk(fv, fn)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,8 +41,18 @@ type Config struct {
|
|||||||
Chat ChatCfg `yaml:"chat"`
|
Chat ChatCfg `yaml:"chat"`
|
||||||
Paths PathsCfg `yaml:"paths"`
|
Paths PathsCfg `yaml:"paths"`
|
||||||
Update UpdateCfg `yaml:"update"`
|
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).
|
// GitCfg — источник репозиториев (для git clone).
|
||||||
type GitCfg struct {
|
type GitCfg struct {
|
||||||
BaseURL string `yaml:"base_url" env:"GIT_BASE_URL"`
|
BaseURL string `yaml:"base_url" env:"GIT_BASE_URL"`
|
||||||
@@ -78,6 +89,23 @@ type OpenCodeCfg struct {
|
|||||||
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
||||||
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
|
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
|
||||||
PollMs Duration `yaml:"poll_ms" default:"2s"`
|
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 {
|
type ChatCfg struct {
|
||||||
@@ -99,5 +127,8 @@ func (c *Config) Validate() error {
|
|||||||
if c.Telegram.ChatID == "" {
|
if c.Telegram.ChatID == "" {
|
||||||
errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField))
|
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...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,27 @@ type Core struct {
|
|||||||
MaxQuestionsPerTurn int
|
MaxQuestionsPerTurn int
|
||||||
// Live — опциональный просмотр живой сессии задачи (для /status N).
|
// Live — опциональный просмотр живой сессии задачи (для /status N).
|
||||||
Live LiveProber
|
Live LiveProber
|
||||||
|
// Events — издатель доменных событий для UI. nil — события выключены.
|
||||||
|
Events events.Publisher
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish отправляет доменное событие, если задан издатель.
|
||||||
|
func (c *Core) publish(e events.Event) {
|
||||||
|
if c.Events != nil {
|
||||||
|
c.Events.Publish(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStatus переводит задачу в новый статус: сохраняет в БД и публикует событие.
|
||||||
|
// from фиксируется до перехода (для TaskStatusChanged).
|
||||||
|
func (c *Core) setStatus(ctx context.Context, task *storage.Task, to storage.Status) error {
|
||||||
|
from := task.Status
|
||||||
|
task.Status = to
|
||||||
|
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: to})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LiveProber — абстракция за журналом живых сессий (реализация — *opencode.LiveRegistry).
|
// LiveProber — абстракция за журналом живых сессий (реализация — *opencode.LiveRegistry).
|
||||||
@@ -52,7 +74,7 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
|
|||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. согласие в фазе ready → создание
|
// 2. согласие в фазе ready → одобрение
|
||||||
if task.Status == storage.StatusReady {
|
if task.Status == storage.StatusReady {
|
||||||
if isConsent(text) {
|
if isConsent(text) {
|
||||||
return c.handleConsent(ctx, task)
|
return c.handleConsent(ctx, task)
|
||||||
@@ -61,6 +83,16 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
|
|||||||
return c.handleEdit(ctx, task, text)
|
return c.handleEdit(ctx, task, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2b. approved — финальное одобрение, правка запрещена.
|
||||||
|
// Воркер уже взял/заберёт задачу; текст не меняет статус.
|
||||||
|
if task.Status == storage.StatusApproved {
|
||||||
|
return Result{
|
||||||
|
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
|
||||||
|
TaskID: task.ID,
|
||||||
|
Status: task.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// 3. обычный ход: накопление + аналитик
|
// 3. обычный ход: накопление + аналитик
|
||||||
return c.handleTurn(ctx, task, text)
|
return c.handleTurn(ctx, task, text)
|
||||||
}
|
}
|
||||||
@@ -95,7 +127,6 @@ func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Re
|
|||||||
default:
|
default:
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
|
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
|
||||||
Action: "send",
|
|
||||||
TaskID: taskID,
|
TaskID: taskID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -108,16 +139,14 @@ func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
task.Status = storage.StatusCollecting
|
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
|
||||||
if err := c.Store.ClearHistory(ctx, task.ID); err != nil {
|
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
if err := c.Store.ClearHistory(ctx, task.ID); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: greeting,
|
Reply: greeting,
|
||||||
Action: "greeting",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -129,13 +158,11 @@ func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
task.Status = storage.StatusCancelled
|
if err := c.setStatus(ctx, task, storage.StatusCancelled); err != nil {
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "🚫 Отменил.",
|
Reply: "🚫 Отменил.",
|
||||||
Action: "drop",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -150,7 +177,6 @@ func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
|
|||||||
if task.Status == storage.StatusReady {
|
if task.Status == storage.StatusReady {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Напишите «создавай» — или правьте текст.",
|
Reply: "Напишите «создавай» — или правьте текст.",
|
||||||
Action: "send",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -181,23 +207,29 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Укажите номер задачи: `/retry 5`.",
|
Reply: "Укажите номер задачи: `/retry 5`.",
|
||||||
Action: "send",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
task, err := c.Store.GetTask(ctx, id)
|
task, err := c.Store.GetTask(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.notFoundReply(ctx, id, err)
|
return c.notFoundReply(ctx, id, err)
|
||||||
}
|
}
|
||||||
task.Status = storage.StatusCollecting
|
// Завершённые задачи (success/cancelled/aborted/closed) перезапускать нельзя:
|
||||||
if err := c.Store.ClearHistory(ctx, id); err != nil {
|
// переход → collecting для них невалиден. Только новая задача через /start.
|
||||||
|
if storage.IsTerminal(task.Status) {
|
||||||
|
return Result{
|
||||||
|
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
|
||||||
|
TaskID: id,
|
||||||
|
Status: task.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
if err := c.Store.ClearHistory(ctx, id); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Задача перезапущена. Опишите, что меняем:",
|
Reply: "Задача перезапущена. Опишите, что меняем:",
|
||||||
Action: "send",
|
|
||||||
TaskID: id,
|
TaskID: id,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -209,7 +241,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Укажите номер задачи: `/status 5`.",
|
Reply: "Укажите номер задачи: `/status 5`.",
|
||||||
Action: "send",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
task, err := c.Store.GetTask(ctx, id)
|
task, err := c.Store.GetTask(ctx, id)
|
||||||
@@ -225,7 +256,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
|
|||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: reply,
|
Reply: reply,
|
||||||
Action: "send",
|
|
||||||
TaskID: id,
|
TaskID: id,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -237,7 +267,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
|||||||
if !ok {
|
if !ok {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Укажите номер задачи: `/continue 5`.",
|
Reply: "Укажите номер задачи: `/continue 5`.",
|
||||||
Action: "send",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
task, err := c.Store.GetTask(ctx, id)
|
task, err := c.Store.GetTask(ctx, id)
|
||||||
@@ -247,7 +276,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
|||||||
return Result{
|
return Result{
|
||||||
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
|
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
|
||||||
". Резюм сессии — пока не реализован.",
|
". Резюм сессии — пока не реализован.",
|
||||||
Action: "send",
|
|
||||||
TaskID: id,
|
TaskID: id,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -257,19 +285,17 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
|||||||
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
|
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Задача #" + itoa(id) + " не найдена.",
|
Reply: "Задача #" + itoa(id) + " не найдена.",
|
||||||
Action: "send",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConsent создаёт задачу (статус ready → ...). Пока — подтверждение готовности.
|
// handleConsent одобряет задачу: ready → approved (финальное одобрение,
|
||||||
|
// после которого воркер забирает задачу на выполнение).
|
||||||
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
|
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
|
||||||
task.Status = storage.StatusReady
|
if err := c.setStatus(ctx, task, storage.StatusApproved); err != nil {
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "✅ Задача #" + itoa(task.ID) + " готова к запуску.",
|
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
|
||||||
Action: "created:" + itoa(task.ID),
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -277,6 +303,7 @@ func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, e
|
|||||||
|
|
||||||
// handleEdit — правка черновика в фазе ready → снова сбор + аналитик.
|
// handleEdit — правка черновика в фазе ready → снова сбор + аналитик.
|
||||||
func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string) (Result, error) {
|
func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string) (Result, error) {
|
||||||
|
from := task.Status
|
||||||
task.Status = storage.StatusCollecting
|
task.Status = storage.StatusCollecting
|
||||||
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
|
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -284,6 +311,7 @@ func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string)
|
|||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status})
|
||||||
return c.runDecide(ctx, task, false)
|
return c.runDecide(ctx, task, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,10 +320,12 @@ func (c *Core) handleTurn(ctx context.Context, task *storage.Task, text string)
|
|||||||
// переход draft→collecting нужно персистить до вызова аналитика,
|
// переход draft→collecting нужно персистить до вызова аналитика,
|
||||||
// иначе propose сделает draft→ready (невалидно)
|
// иначе propose сделает draft→ready (невалидно)
|
||||||
if task.Status != storage.StatusCollecting {
|
if task.Status != storage.StatusCollecting {
|
||||||
|
from := task.Status
|
||||||
task.Status = storage.StatusCollecting
|
task.Status = storage.StatusCollecting
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status})
|
||||||
}
|
}
|
||||||
if text != "" {
|
if text != "" {
|
||||||
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
|
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
|
||||||
@@ -311,6 +341,23 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
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))
|
msgs := make([]Message, 0, len(history))
|
||||||
for _, h := range history {
|
for _, h := range history {
|
||||||
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
|
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
|
||||||
@@ -323,47 +370,42 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
|||||||
|
|
||||||
switch decision.Phase {
|
switch decision.Phase {
|
||||||
case "abort":
|
case "abort":
|
||||||
task.Status = storage.StatusAborted
|
if err := c.setStatus(ctx, task, storage.StatusAborted); err != nil {
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
reply := decision.ChatReply
|
reply := decision.ChatReply
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
reply = "Недостаточно данных. Начните заново (/start)."
|
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":
|
case "propose", "ready":
|
||||||
// применяем черновик (для ready — текущий, без изменений)
|
// применяем черновик (для ready — текущий, без изменений)
|
||||||
applyDraft(task, decision.Draft)
|
applyDraft(task, decision.Draft)
|
||||||
// E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить.
|
// E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить.
|
||||||
if len(task.EffectiveRepos()) == 0 {
|
if len(task.EffectiveRepos()) == 0 {
|
||||||
task.Status = storage.StatusCollecting
|
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
|
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
|
||||||
return Result{Reply: reply, 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.setStatus(ctx, task, storage.StatusReady); err != nil {
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: formatSummary(*task),
|
Reply: formatSummary(*task),
|
||||||
Action: "summary",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
default: // "ask"
|
default: // "ask"
|
||||||
applyDraft(task, decision.Draft)
|
applyDraft(task, decision.Draft)
|
||||||
task.Status = storage.StatusCollecting
|
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
|
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
|
||||||
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
|
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
@@ -44,13 +45,11 @@ func TestStartCreatesCollecting(t *testing.T) {
|
|||||||
c, ctx, store := setupCore(t, nil)
|
c, ctx, store := setupCore(t, nil)
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "/start")
|
_, err := c.ProcessTurn(ctx, id, "/start")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn /start: %v", err)
|
t.Fatalf("ProcessTurn /start: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "greeting" {
|
|
||||||
t.Fatalf("action = %q, want greeting", res.Action)
|
|
||||||
}
|
|
||||||
task, _ := store.GetTask(ctx, id)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusCollecting {
|
if task.Status != storage.StatusCollecting {
|
||||||
t.Fatalf("status = %s, want collecting", task.Status)
|
t.Fatalf("status = %s, want collecting", task.Status)
|
||||||
@@ -61,13 +60,11 @@ func TestCancelSetsCancelled(t *testing.T) {
|
|||||||
c, ctx, store := setupCore(t, nil)
|
c, ctx, store := setupCore(t, nil)
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "/cancel")
|
_, err := c.ProcessTurn(ctx, id, "/cancel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn /cancel: %v", err)
|
t.Fatalf("ProcessTurn /cancel: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "drop" {
|
|
||||||
t.Fatalf("action = %q, want drop", res.Action)
|
|
||||||
}
|
|
||||||
task, _ := store.GetTask(ctx, id)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusCancelled {
|
if task.Status != storage.StatusCancelled {
|
||||||
t.Fatalf("status = %s, want cancelled", task.Status)
|
t.Fatalf("status = %s, want cancelled", task.Status)
|
||||||
@@ -84,13 +81,11 @@ func TestSingleTurnPropose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
|
_, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn: %v", err)
|
t.Fatalf("ProcessTurn: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "summary" {
|
|
||||||
t.Fatalf("action = %q, want summary", res.Action)
|
|
||||||
}
|
|
||||||
task, _ := store.GetTask(ctx, id)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusReady {
|
if task.Status != storage.StatusReady {
|
||||||
t.Fatalf("status = %s, want ready", task.Status)
|
t.Fatalf("status = %s, want ready", task.Status)
|
||||||
@@ -114,9 +109,7 @@ func TestAskReturnsQuestions(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn: %v", err)
|
t.Fatalf("ProcessTurn: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "send" {
|
|
||||||
t.Fatalf("action = %q, want send", res.Action)
|
|
||||||
}
|
|
||||||
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
|
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
|
||||||
t.Fatalf("reply = %q", res.Reply)
|
t.Fatalf("reply = %q", res.Reply)
|
||||||
}
|
}
|
||||||
@@ -133,13 +126,11 @@ func TestAbortReturnsDrop(t *testing.T) {
|
|||||||
})
|
})
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "привет")
|
_, err := c.ProcessTurn(ctx, id, "привет")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn: %v", err)
|
t.Fatalf("ProcessTurn: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "drop" {
|
|
||||||
t.Fatalf("action = %q, want drop", res.Action)
|
|
||||||
}
|
|
||||||
task, _ := store.GetTask(ctx, id)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusAborted {
|
if task.Status != storage.StatusAborted {
|
||||||
t.Fatalf("status = %s, want aborted", task.Status)
|
t.Fatalf("status = %s, want aborted", task.Status)
|
||||||
@@ -153,12 +144,14 @@ func TestConsentInReady(t *testing.T) {
|
|||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
|
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
|
||||||
res, err := c.ProcessTurn(ctx, id, "создавай")
|
_, err := c.ProcessTurn(ctx, id, "создавай")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn создавай: %v", err)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,13 +165,11 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
|
|||||||
|
|
||||||
_, _ = c.ProcessTurn(ctx, id, "сделай X")
|
_, _ = c.ProcessTurn(ctx, id, "сделай X")
|
||||||
// в ready пишем правку, не согласие
|
// в ready пишем правку, не согласие
|
||||||
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
|
_, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn edit: %v", err)
|
t.Fatalf("ProcessTurn edit: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "summary" {
|
|
||||||
t.Fatalf("action = %q, want summary", res.Action)
|
|
||||||
}
|
|
||||||
if calls != 2 {
|
if calls != 2 {
|
||||||
t.Fatalf("decide calls = %d, want 2", calls)
|
t.Fatalf("decide calls = %d, want 2", calls)
|
||||||
}
|
}
|
||||||
@@ -190,25 +181,59 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
|
|||||||
|
|
||||||
func TestRetryNotFound(t *testing.T) {
|
func TestRetryNotFound(t *testing.T) {
|
||||||
c, ctx, _ := setupCore(t, nil)
|
c, ctx, _ := setupCore(t, nil)
|
||||||
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
|
_, err := c.ProcessTurn(ctx, 999, "/retry 999")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn /retry: %v", err)
|
t.Fatalf("ProcessTurn /retry: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "send" {
|
|
||||||
t.Fatalf("action = %q, want send", res.Action)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownCommand(t *testing.T) {
|
func TestUnknownCommand(t *testing.T) {
|
||||||
c, ctx, store := setupCore(t, nil)
|
c, ctx, store := setupCore(t, nil)
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "/bogus")
|
_, err := c.ProcessTurn(ctx, id, "/bogus")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn: %v", err)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ type Decider interface {
|
|||||||
// Result — результат одного хода.
|
// Result — результат одного хода.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Reply string
|
Reply string
|
||||||
Action string // send | summary | created:N | abort | drop | greeting
|
|
||||||
TaskID int64
|
TaskID int64
|
||||||
Status storage.Status
|
Status storage.Status
|
||||||
}
|
}
|
||||||
|
|||||||
110
internal/events/bus.go
Normal file
110
internal/events/bus.go
Normal 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
119
internal/events/bus_test.go
Normal 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
56
internal/events/events.go
Normal 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
96
internal/events/hub.go
Normal 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
119
internal/events/hub_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHubDeliversTypedEvent(t *testing.T) {
|
||||||
|
bus := New(16)
|
||||||
|
h := NewHub(bus)
|
||||||
|
|
||||||
|
var got atomic.Value
|
||||||
|
On[TaskStatusChanged](h, func(e TaskStatusChanged) {
|
||||||
|
got.CompareAndSwap(nil, e)
|
||||||
|
})
|
||||||
|
|
||||||
|
h.Start()
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
want := TaskStatusChanged{ID: 7, From: model.StatusReady, To: model.StatusRunning}
|
||||||
|
bus.Publish(want)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if raw := got.Load(); raw != nil {
|
||||||
|
ev := raw.(TaskStatusChanged)
|
||||||
|
if ev.ID != 7 || ev.From != model.StatusReady || ev.To != model.StatusRunning {
|
||||||
|
t.Fatalf("unexpected event: %+v", ev)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("handler was not called")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubIgnoresUnrelatedTypes(t *testing.T) {
|
||||||
|
bus := New(16)
|
||||||
|
h := NewHub(bus)
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
On[TaskStatusChanged](h, func(TaskStatusChanged) { calls.Add(1) })
|
||||||
|
|
||||||
|
h.Start()
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
// другие типы событий не должны дойти до этого обработчика
|
||||||
|
bus.Publish(AgentActivity{TaskID: 1, Agent: "dev", Stage: "run"})
|
||||||
|
bus.Publish(LogLine{Level: "log", Text: "x"})
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
if n := calls.Load(); n != 0 {
|
||||||
|
t.Fatalf("handler called %d times for unrelated events", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubMultipleHandlersSameType(t *testing.T) {
|
||||||
|
bus := New(16)
|
||||||
|
h := NewHub(bus)
|
||||||
|
|
||||||
|
var a, b atomic.Int32
|
||||||
|
On[HistoryAppended](h, func(HistoryAppended) { a.Add(1) })
|
||||||
|
On[HistoryAppended](h, func(HistoryAppended) { b.Add(1) })
|
||||||
|
|
||||||
|
h.Start()
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
bus.Publish(HistoryAppended{TaskID: 1, Role: "user", Content: "hi"})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if a.Load() == 1 && b.Load() == 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("not all handlers called")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubCloseStopsDelivery(t *testing.T) {
|
||||||
|
bus := New(16)
|
||||||
|
h := NewHub(bus)
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
On[TaskCreated](h, func(TaskCreated) { calls.Add(1) })
|
||||||
|
|
||||||
|
h.Start()
|
||||||
|
bus.Publish(TaskCreated{ID: 1})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && calls.Load() == 0 {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if calls.Load() == 0 {
|
||||||
|
t.Fatal("initial delivery failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Close()
|
||||||
|
// после Close Hub отписан — события не доходят
|
||||||
|
bus.Publish(TaskCreated{ID: 2})
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
if n := calls.Load(); n > 1 {
|
||||||
|
t.Fatalf("handler called %d times after Close", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubStartIdempotent(t *testing.T) {
|
||||||
|
bus := New(16)
|
||||||
|
h := NewHub(bus)
|
||||||
|
h.Start()
|
||||||
|
h.Start() // повторный Start не должен создавать вторую горутину
|
||||||
|
h.Close()
|
||||||
|
// если бы было две горутины — Publish блокировался бы на буфере, но закрытие сняло бы блок;
|
||||||
|
// просто проверяем, что Close и повторный Start не падают
|
||||||
|
bus.Publish(TaskCreated{ID: 1})
|
||||||
|
}
|
||||||
105
internal/events/log.go
Normal file
105
internal/events/log.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Канонические уровни записей лога (фильтр панели «Логи», см. ui.LogLevels).
|
||||||
|
const (
|
||||||
|
LevelError = "error"
|
||||||
|
LevelWarning = "warning"
|
||||||
|
LevelInfo = "info"
|
||||||
|
LevelDebug = "debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogLine — строка лога для панели «Логи».
|
||||||
|
type LogLine struct {
|
||||||
|
Level string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LogLine) _event() {}
|
||||||
|
|
||||||
|
// markers проверяет наличие любого из маркеров в уже приведённой к нижнему
|
||||||
|
// регистру строке.
|
||||||
|
func markers(s string, anyOf ...string) bool {
|
||||||
|
for _, m := range anyOf {
|
||||||
|
if strings.Contains(s, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyLevel определяет уровень строки лога по её тексту.
|
||||||
|
//
|
||||||
|
// Стандартный log.Printf не несёт уровня, поэтому применяются эвристики по
|
||||||
|
// маркерам в тексте (в приоритете debug → warning → error): "debug"/"trace" —
|
||||||
|
// отладка; "warn"/"предупреж" — предупреждение; "error"/"ошибк"/"failed"/
|
||||||
|
// "fatal"/"panic" — ошибка. Остальные строки — информационные.
|
||||||
|
func ClassifyLevel(line string) string {
|
||||||
|
s := strings.ToLower(line)
|
||||||
|
switch {
|
||||||
|
case markers(s, "debug", "trace"):
|
||||||
|
return LevelDebug
|
||||||
|
case markers(s, "warn", "предупреж"):
|
||||||
|
return LevelWarning
|
||||||
|
case markers(s, "error", "ошибк", "failed", "failure", "fatal", "panic"):
|
||||||
|
return LevelError
|
||||||
|
default:
|
||||||
|
return LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogBus — тип-обёртка над *Bus для логов.
|
||||||
|
//
|
||||||
|
// Логи идут отдельной шиной, чтобы большие объёмы текста не блокировали
|
||||||
|
// доменные события и наоборот.
|
||||||
|
type LogBus struct {
|
||||||
|
*Bus
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogBus создаёт шину логов с буфером на подписчика.
|
||||||
|
func NewLogBus(bufSize int) *LogBus {
|
||||||
|
return &LogBus{Bus: New(bufSize)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogWriter — io.Writer, который публикует каждую строку лога как LogLine.
|
||||||
|
// Предполагается использование через log.SetOutput в связке, чтобы всё
|
||||||
|
// логирование приложения попадало и в панель «Логи».
|
||||||
|
type LogWriter struct {
|
||||||
|
bus *Bus
|
||||||
|
mu sync.Mutex // защищает остаток частичной строки
|
||||||
|
buf strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogWriter создаёт LogWriter, публикующий строки лога как события
|
||||||
|
// LogLine; уровень каждой строки определяется эвристикой ClassifyLevel.
|
||||||
|
func NewLogWriter(bus *LogBus) *LogWriter {
|
||||||
|
return &LogWriter{bus: bus.Bus}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write реализует io.Writer. Данные разрезаются по переводам строки:
|
||||||
|
// каждая законченная строка публикуется отдельным событием.
|
||||||
|
func (w *LogWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
w.buf.Write(p)
|
||||||
|
data := w.buf.String()
|
||||||
|
for {
|
||||||
|
idx := strings.IndexByte(data, '\n')
|
||||||
|
if idx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line := strings.TrimSuffix(data[:idx], "\r")
|
||||||
|
data = data[idx+1:]
|
||||||
|
if line != "" {
|
||||||
|
w.bus.Publish(LogLine{Level: ClassifyLevel(line), Text: line})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.buf.Reset()
|
||||||
|
w.buf.WriteString(data)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
122
internal/events/log_test.go
Normal file
122
internal/events/log_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogWriterLines(t *testing.T) {
|
||||||
|
lbus := NewLogBus(16)
|
||||||
|
ch, unsub := lbus.Subscribe()
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
w := NewLogWriter(lbus)
|
||||||
|
if _, err := w.Write([]byte("first line\nsecond line\n")); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := receiveOne(t, ch)
|
||||||
|
ll, ok := first.(LogLine)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("got %T, want LogLine", first)
|
||||||
|
}
|
||||||
|
// строка без маркеров ошибок/отладки — информационная
|
||||||
|
if ll.Text != "first line" || ll.Level != LevelInfo {
|
||||||
|
t.Fatalf("unexpected first line: %+v", ll)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := receiveOne(t, ch)
|
||||||
|
if ll, ok := second.(LogLine); !ok || ll.Text != "second line" {
|
||||||
|
t.Fatalf("unexpected second line: %+v", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterPartialLine(t *testing.T) {
|
||||||
|
lbus := NewLogBus(16)
|
||||||
|
ch, unsub := lbus.Subscribe()
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
w := NewLogWriter(lbus)
|
||||||
|
if _, err := w.Write([]byte("partial")); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-ch:
|
||||||
|
t.Fatalf("partial line should not be published yet, got %#v", got)
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write([]byte(" line\n")); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
got := receiveOne(t, ch)
|
||||||
|
ll, ok := got.(LogLine)
|
||||||
|
if !ok || ll.Text != "partial line" {
|
||||||
|
t.Fatalf("unexpected: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterMultiSplit(t *testing.T) {
|
||||||
|
lbus := NewLogBus(16)
|
||||||
|
ch, unsub := lbus.Subscribe()
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
w := NewLogWriter(lbus)
|
||||||
|
if _, err := w.Write([]byte("line1\nline2\nline3\n")); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
var texts []string
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ev := receiveOne(t, ch)
|
||||||
|
texts = append(texts, ev.(LogLine).Text)
|
||||||
|
}
|
||||||
|
if strings.Join(texts, ",") != "line1,line2,line3" {
|
||||||
|
t.Fatalf("got %v", texts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyLevel определяет уровень строки по маркерам в тексте.
|
||||||
|
func TestClassifyLevel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
line string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"app: db opened /tmp/r.db", LevelInfo},
|
||||||
|
{"app: worker started", LevelInfo},
|
||||||
|
{"opencode: debug: poll request", LevelDebug},
|
||||||
|
{"trace: session resumed", LevelDebug},
|
||||||
|
{"tg: warn: long poll timeout", LevelWarning},
|
||||||
|
{"ПРЕДУПРЕЖДЕНИЕ: конфиг не задан", LevelWarning},
|
||||||
|
{"app: process /start #5: run failed: exit status 1", LevelError},
|
||||||
|
{"ERROR: connection refused", LevelError},
|
||||||
|
{"worker: job failed after 3 retries", LevelError},
|
||||||
|
{"panic: runtime error", LevelError},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ClassifyLevel(c.line); got != c.want {
|
||||||
|
t.Errorf("ClassifyLevel(%q) = %q, want %q", c.line, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogWriter публикует уровень, определённый по тексту строки.
|
||||||
|
func TestLogWriterClassifiesLevels(t *testing.T) {
|
||||||
|
lbus := NewLogBus(16)
|
||||||
|
ch, unsub := lbus.Subscribe()
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
w := NewLogWriter(lbus)
|
||||||
|
if _, err := w.Write([]byte("all good\nERROR: boom\n")); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := receiveOne(t, ch).(LogLine)
|
||||||
|
if first.Level != LevelInfo || first.Text != "all good" {
|
||||||
|
t.Fatalf("unexpected first: %+v", first)
|
||||||
|
}
|
||||||
|
second := receiveOne(t, ch).(LogLine)
|
||||||
|
if second.Level != LevelError || second.Text != "ERROR: boom" {
|
||||||
|
t.Fatalf("unexpected second: %+v", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/events/publisher.go
Normal file
20
internal/events/publisher.go
Normal 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
77
internal/model/status.go
Normal 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"
|
||||||
|
)
|
||||||
50
internal/model/status_test.go
Normal file
50
internal/model/status_test.go
Normal 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
331
internal/opencode/client.go
Normal 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] + "..."
|
||||||
|
}
|
||||||
301
internal/opencode/client_test.go
Normal file
301
internal/opencode/client_test.go
Normal 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
176
internal/opencode/config.go
Normal 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
|
||||||
|
}
|
||||||
85
internal/opencode/config_test.go
Normal file
85
internal/opencode/config_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
|
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
|
||||||
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
|
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
|
||||||
// - ExtractJSON: fenced ```json``` → первый {...}
|
// - ExtractJSON: fenced ```json``` → первый {...}
|
||||||
// - Run/ResumeDev: запуск процесса с idle/hard timeout по opencode.db
|
|
||||||
package opencode
|
package opencode
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,7 +15,6 @@ import (
|
|||||||
var (
|
var (
|
||||||
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
||||||
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
|
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
|
||||||
sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
|
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
|
||||||
@@ -76,11 +74,12 @@ func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionIDFromOutput извлекает session_id из текстового вывода opencode.
|
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
|
||||||
func SessionIDFromOutput(out string) (string, bool) {
|
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
|
||||||
m := sessionRe.FindStringSubmatch(out)
|
func stripFence(s string) string {
|
||||||
if len(m) > 1 && m[1] != "" {
|
s = strings.TrimSpace(s)
|
||||||
return m[1], true
|
if f := fenceRe.FindStringSubmatch(s); len(f) > 1 {
|
||||||
|
s = strings.TrimSpace(f[1])
|
||||||
}
|
}
|
||||||
return "", false
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,36 +2,11 @@ package opencode
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseLiveStep пытается распарсить одну NDJSON-строку stdout opencode как
|
// LiveStep — один наблюдаемый шаг агента.
|
||||||
// событие (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, не из БД.
|
|
||||||
type LiveStep struct {
|
type LiveStep struct {
|
||||||
Type string // "text" | "tool" | "agent" | ...
|
Type string // "text" | "tool" | "agent" | ...
|
||||||
Text string // содержимое text-парта (для других типов может быть пустым)
|
Text string // содержимое text-парта (для других типов может быть пустым)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
234
internal/opencode/pool.go
Normal 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
|
||||||
|
}
|
||||||
75
internal/opencode/pool_test.go
Normal file
75
internal/opencode/pool_test.go
Normal 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]
|
||||||
|
}
|
||||||
24
internal/opencode/procgroup_unix.go
Normal file
24
internal/opencode/procgroup_unix.go
Normal 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)
|
||||||
|
}
|
||||||
20
internal/opencode/procgroup_windows.go
Normal file
20
internal/opencode/procgroup_windows.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -1,48 +1,44 @@
|
|||||||
package opencode
|
package opencode
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result — результат запуска opencode run. rc=-1 означает «убит по таймауту»
|
// Result — результат запуска opencode-субагента через HTTP API. rc=-1 означает
|
||||||
// (idle/hard): вызывающий НЕ должен ронять задачу, а обязан закоммитить/запушить
|
// «оборван по таймауту/контексту» (idle/hard): вызывающий НЕ должен ронять
|
||||||
// готовую работу и отправить на ревью (класс O2 Timeout — результат, не ошибка).
|
// задачу, а обязан закоммитить/запушить готовую работу и отправить на ревью
|
||||||
|
// (класс O2 Timeout — результат, не ошибка).
|
||||||
type Result struct {
|
type Result struct {
|
||||||
RC int
|
RC int
|
||||||
Stdout string
|
Stdout string
|
||||||
SessionID 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 {
|
type Runner struct {
|
||||||
Bin string // путь к opencode (по умолчанию "opencode")
|
Pool *Pool // пул serve-серверов (обязательный)
|
||||||
DBPath string // путь к opencode.db (idle-детекция активности)
|
IdleTimeout time.Duration
|
||||||
Config string // путь к opencode.json (OPENCODE_CONFIG)
|
HardTimeout time.Duration
|
||||||
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
|
|
||||||
IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
|
|
||||||
HardTimeout time.Duration // общий лимит на запуск
|
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
|
Debug bool // отладочные логи API-вызовов (из log.level=debug)
|
||||||
|
|
||||||
// Заменяемые для тестов:
|
// Заменяемый для тестов:
|
||||||
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
|
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) defaults() {
|
func (r *Runner) defaults() {
|
||||||
if r.Bin == "" {
|
|
||||||
r.Bin = "opencode"
|
|
||||||
}
|
|
||||||
if r.IdleTimeout == 0 {
|
if r.IdleTimeout == 0 {
|
||||||
r.IdleTimeout = 5 * time.Minute
|
r.IdleTimeout = 5 * time.Minute
|
||||||
}
|
}
|
||||||
@@ -61,232 +57,170 @@ func (r *Runner) logf(format string, args ...any) {
|
|||||||
fmt.Fprintf(r.Stdout, format+"\n", args...)
|
fmt.Fprintf(r.Stdout, format+"\n", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxDirMsgTS — максимальный time_updated (мс) по всем сообщениям сессий этого
|
// Run запускает opencode-субагента через HTTP API: создаёт/продолжает сессию
|
||||||
// worktree: сигнал «модель/субагенты ещё активны». nil-nil если БД нет/пуста.
|
// в сервере пула для каталога cwd, отправляет промпт, ждёт вердикт.
|
||||||
func (r *Runner) maxDirMsgTS(ctx context.Context, worktree string) (int64, bool) {
|
//
|
||||||
if r.DBPath == "" {
|
// Возвращает *Result (rc, stdout=вердикт, session_id). Ошибка — только класс
|
||||||
return 0, false
|
// O1 ErrRun (не смог обратиться к серверу/сессии). Таймауты дают rc=-1 в
|
||||||
}
|
// Result, а не error (класс O2).
|
||||||
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).
|
|
||||||
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
|
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
|
||||||
r.defaults()
|
r.defaults()
|
||||||
cmd := []string{r.Bin, "run", "--agent", agent, "--format", "json", "--dir", cwd}
|
if r.Pool == nil {
|
||||||
if sessionID != "" {
|
return nil, fmt.Errorf("opencode: Pool не задан (API-режим обязателен)")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proc := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
|
srv, err := r.Pool.Ensure(ctx, cwd)
|
||||||
proc.Env = env
|
|
||||||
proc.Dir = cwd
|
|
||||||
// Убиваем всю process-group, чтобы дочерние процессы (sleep и т.п.) тоже
|
|
||||||
// умерли и закрыли унаследованные stdout-fd (иначе <-done виснет).
|
|
||||||
setpgid(proc)
|
|
||||||
stdout, err := proc.StdoutPipe()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opencode: stdout pipe: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
proc.Stderr = proc.Stdout
|
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug}
|
||||||
if err := proc.Start(); err != nil {
|
|
||||||
return nil, fmt.Errorf("opencode: start %v: %w", cmd[0], err)
|
// Модель по умолчанию из конфига 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
|
// Сессия: заданная (resume) или новая.
|
||||||
var mu sync.Mutex
|
sid := sessionID
|
||||||
done := make(chan struct{})
|
if sid == "" {
|
||||||
// liveSeq — кол-во распознанных live-строк (text/tool/agent/reasoning) в
|
sid, err = c.CreateSession(ctx, model)
|
||||||
// NDJSON-потоке. Инкрементится из goroutine чтения; поллинг сравнивает,
|
if err != nil {
|
||||||
// чтобы сбросить idle-таймер «пока LLM стримит» (а не только по БД).
|
return nil, fmt.Errorf("opencode: create session: %w", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
go func() {
|
r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr())
|
||||||
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()
|
lastProgress := time.Now()
|
||||||
launch := time.Now()
|
launch := time.Now()
|
||||||
killed := false
|
|
||||||
|
|
||||||
pollLoop:
|
doneSeen, emptySeen := 0, 0
|
||||||
for {
|
|
||||||
select {
|
abortAnd := func(rc int, why string) (*Result, error) {
|
||||||
case <-done:
|
if err := c.Interrupt(ctx, sid); err != nil {
|
||||||
// процесс завершился (pipe EOF) — выходим, берём exit code
|
r.logf("opencode(%s) interrupt %s: %v", agent, why, err)
|
||||||
break pollLoop
|
|
||||||
case <-ctx.Done():
|
|
||||||
killGroup(proc)
|
|
||||||
killed = true
|
|
||||||
break pollLoop
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
if proc.ProcessState != nil && proc.ProcessState.Exited() {
|
return &Result{RC: rc, Stdout: "", SessionID: sid}, nil
|
||||||
break pollLoop
|
}
|
||||||
|
|
||||||
|
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()
|
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 {
|
if now.Sub(lastProgress) > r.IdleTimeout {
|
||||||
r.logf("opencode(%s) idle %.0fs (нет новых сообщений) — kill", agent, r.IdleTimeout.Seconds())
|
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
|
||||||
killGroup(proc)
|
return abortAnd(-1, "idle")
|
||||||
killed = true
|
|
||||||
break pollLoop
|
|
||||||
}
|
}
|
||||||
if now.Sub(launch) > r.HardTimeout {
|
if now.Sub(launch) > r.HardTimeout {
|
||||||
r.logf("opencode(%s) hard timeout %.0fs — kill", agent, r.HardTimeout.Seconds())
|
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
|
||||||
killGroup(proc)
|
return abortAnd(-1, "hard")
|
||||||
killed = true
|
|
||||||
break pollLoop
|
|
||||||
}
|
|
||||||
time.Sleep(r.PollInterval)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<-done
|
switch {
|
||||||
procErr := proc.Wait()
|
case !active && cur != nil && cur.finished():
|
||||||
rc := proc.ProcessState.ExitCode()
|
// ответ закончен — ждём стабильности, затем собираем вердикт
|
||||||
if rc < 0 {
|
doneSeen++
|
||||||
rc = 1
|
emptySeen = 0
|
||||||
|
if doneSeen >= settlePolls {
|
||||||
|
return r.verdict(model, cur, msgs, admittedAt, sid)
|
||||||
}
|
}
|
||||||
if killed {
|
case !active && cur == nil:
|
||||||
rc = -1
|
// сессия завершилась, но нового assistant-сообщения так и нет
|
||||||
|
emptySeen++
|
||||||
|
if emptySeen >= settlePolls {
|
||||||
|
return nil, &ClientErr{Op: "prompt", Err: errors.New("агент не выдал ответ (сессия пуста)")}
|
||||||
}
|
}
|
||||||
_ = procErr
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.logf("opencode(%s) rc=%d", agent, rc)
|
|
||||||
return &Result{RC: rc, Stdout: out, SessionID: sid}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
|
select {
|
||||||
// падает с rc!=0 (напр. сессия потеряна) — повторяем ОДИН раз свежей сессией
|
case <-time.After(r.PollInterval):
|
||||||
// в том же worktree. rc=-1 (kill по таймауту) НЕ триггерит fallback.
|
case <-ctx.Done():
|
||||||
// Возвращает (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
|
|
||||||
}
|
}
|
||||||
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", "")
|
|
||||||
}
|
}
|
||||||
return res, res.RC == -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeFallbackNote = "\n\n(Возобновление сессии не удалось; продолжи с учётом уже сделанных изменений в worktree.)"
|
// verdict собирает финальный результат из новых assistant-сообщений.
|
||||||
|
// Проверяет фактическую модель ответа и логирует warning при расхождении
|
||||||
// --- process-group helpers (Linux) ---
|
// с ожидаемой (устойчивость к «не той» модели — класс O5 WARN).
|
||||||
// Ставим процесс в собственную process-group, чтобы killGroup мог убить и
|
func (r *Runner) verdict(model *ModelRef, cur *v2Message, msgs []v2Message, since int64, sid string) (*Result, error) {
|
||||||
// дочерние процессы (иначе они держат унаследованные stdout-fd и <-done виснет).
|
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)
|
||||||
func setpgid(proc *exec.Cmd) {
|
|
||||||
sysProcAttr(proc)
|
|
||||||
}
|
}
|
||||||
|
if cur.Error != nil && cur.Error.Message != "" {
|
||||||
func killGroup(proc *exec.Cmd) {
|
return nil, &ClientErr{Op: "prompt", Err: errors.New(cur.Error.Message)}
|
||||||
if proc.Process != nil {
|
|
||||||
killProcGroup(proc.Process.Pid)
|
|
||||||
}
|
}
|
||||||
_ = proc.Process.Kill()
|
texts := assistantText(msgs, since)
|
||||||
|
if len(texts) == 0 {
|
||||||
|
return nil, &ClientErr{Op: "prompt", Err: errors.New("нет text-части в ответе")}
|
||||||
|
}
|
||||||
|
vd := stripFence(strings.Join(texts, "\n"))
|
||||||
|
r.logf("opencode вердикт готов (%d байт)", len(vd))
|
||||||
|
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,60 +2,41 @@ package opencode
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"io"
|
||||||
"path/filepath"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeOpenCode создаёт shell-скрипт, имитирующий opencode run:
|
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
|
||||||
//
|
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
|
||||||
// $FAKE_MODE=ok -> мгновенный успех, печатает NDJSON c session_id
|
// XDG_CONFIG_HOME уводится во временный каталог, чтобы ReadModelRef не читал
|
||||||
// $FAKE_MODE=slow-> спит долго (для idle/hard timeout)
|
// реальный пользовательский конфиг opencode (детерминизм тестов).
|
||||||
// $FAKE_MODE=fail-> exit 7 (resume-fallback)
|
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
|
||||||
func fakeOpenCode(t *testing.T, workdir string) string {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
bin := filepath.Join(workdir, "opencode")
|
ts := httptestURL(t, f)
|
||||||
script := `#!/bin/sh
|
p := NewPool(dir)
|
||||||
mode="${FAKE_MODE:-ok}"
|
p.mu.Lock()
|
||||||
case "$mode" in
|
p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond}
|
||||||
ok)
|
p.mu.Unlock()
|
||||||
echo '{"type":"text","part":{"text":"done"}}'
|
return p, &Client{BaseURL: ts}
|
||||||
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
|
|
||||||
|
// 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) {
|
func TestRun_Success(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
f := &fakeAPIServer{verdictText: "done"}
|
||||||
t.Setenv("FAKE_MODE", "ok")
|
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", "")
|
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run err: %v", err)
|
t.Fatalf("Run err: %v", err)
|
||||||
@@ -63,86 +44,81 @@ func TestRun_Success(t *testing.T) {
|
|||||||
if res.RC != 0 {
|
if res.RC != 0 {
|
||||||
t.Errorf("RC = %d, want 0", res.RC)
|
t.Errorf("RC = %d, want 0", res.RC)
|
||||||
}
|
}
|
||||||
if res.SessionID != "sess-123" {
|
if res.SessionID != "sess-fake" {
|
||||||
t.Errorf("SessionID = %q, want sess-123", res.SessionID)
|
t.Errorf("SessionID = %q, want sess-fake", res.SessionID)
|
||||||
}
|
}
|
||||||
if !contains(res.Stdout, "done") {
|
if !contains(res.Stdout, "done") {
|
||||||
t.Errorf("Stdout = %q, want to contain done", res.Stdout)
|
t.Errorf("Stdout = %q, want 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 (должна быть, т.к. задача завершилась)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_IdleTimeout(t *testing.T) {
|
func TestRun_IdleTimeout(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
// агент «завис»: active=true, прогресс не растёт → idle abort
|
||||||
t.Setenv("FAKE_MODE", "slow")
|
f := &fakeAPIServer{blockPrompt: true}
|
||||||
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
|
||||||
PollInterval: 10 * time.Millisecond}
|
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run err: %v", err)
|
t.Fatalf("Run err: %v", err)
|
||||||
}
|
}
|
||||||
if res.RC != -1 {
|
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-таймер должен
|
func TestRun_StreamingGrowth(t *testing.T) {
|
||||||
// сбрасываться, а не убивать процесс по истечении короткого IdleTimeout.
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
func TestRun_LiveResetsIdle(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
// стрим: один text-парт растёт с каждым опросом (дольше, чем idle timeout),
|
||||||
t.Setenv("FAKE_MODE", "live-reset")
|
// но модель жива → idle НЕ должен сработать.
|
||||||
|
f := &fakeAPIServer{streamGrow: true, streamPolls: 30}
|
||||||
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
// idle очень короткий (50мс), hard большой (3с). live-reset стримит ~0.6с.
|
r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond,
|
||||||
// Если live-строки НЕ сбрасывают idle — процесс убьют на ~50мс, и Run
|
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||||
// вернётся быстрее. Если сбрасывают — Run живёт ≥ стрима (~0.6с) до hard.
|
|
||||||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
|
||||||
HardTimeout: 3 * time.Second, PollInterval: 10 * time.Millisecond}
|
|
||||||
start := time.Now()
|
|
||||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||||
elapsed := time.Since(start)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run err: %v", err)
|
t.Fatalf("Run err: %v", err)
|
||||||
}
|
}
|
||||||
if res.RC != -1 {
|
if res.RC != 0 {
|
||||||
t.Errorf("RC = %d, want -1 (killed по hard timeout)", res.RC)
|
t.Errorf("RC = %d, want 0 (растущий стрим не должен считаться hung)", res.RC)
|
||||||
}
|
}
|
||||||
if elapsed < 400*time.Millisecond {
|
if !contains(res.Stdout, "done-stream") {
|
||||||
t.Errorf("Run вернулся за %v — idle убил во время стрима (live не сбросил таймер)", elapsed)
|
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) {
|
func TestRun_ContextCancel(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
f := &fakeAPIServer{blockPrompt: true}
|
||||||
t.Setenv("FAKE_MODE", "slow")
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
r := &Runner{Bin: bin, HardTimeout: time.Minute,
|
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
|
||||||
PollInterval: 10 * time.Millisecond}
|
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||||
done := make(chan *Result, 1)
|
done := make(chan *Result, 1)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
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 {
|
func contains(s, sub string) bool {
|
||||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
|
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
|
||||||
}
|
}
|
||||||
|
|||||||
281
internal/opencode/server.go
Normal file
281
internal/opencode/server.go
Normal 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):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
internal/opencode/server_test.go
Normal file
201
internal/opencode/server_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,62 +1,43 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
// Status — статус задачи (state machine).
|
"github.com/kamelion/ratatoskr-go/internal/model"
|
||||||
type Status string
|
)
|
||||||
|
|
||||||
|
// Status — алиас доменного статуса задачи.
|
||||||
|
//
|
||||||
|
// Совместимый мост: весь внешний код продолжает использовать storage.Status
|
||||||
|
// (например «storage.StatusRunning»), но единый источник истины — model.Status.
|
||||||
|
type Status = model.Status
|
||||||
|
|
||||||
|
// Статусы задачи — re-export из model.
|
||||||
const (
|
const (
|
||||||
StatusDraft Status = "draft" // только что создана
|
StatusDraft = model.StatusDraft
|
||||||
StatusCollecting Status = "collecting" // аналитик собирает детали
|
StatusCollecting = model.StatusCollecting
|
||||||
StatusReady Status = "ready" // черновик готов, ждёт запуска
|
StatusReady = model.StatusReady
|
||||||
StatusRunning Status = "running" // opencode работает
|
StatusApproved = model.StatusApproved
|
||||||
StatusSuccess Status = "success" // задача выполнена
|
StatusRunning = model.StatusRunning
|
||||||
StatusFailed Status = "failed" // ошибка выполнения
|
StatusSuccess = model.StatusSuccess
|
||||||
StatusTimeout Status = "timeout" // таймаут opencode
|
StatusFailed = model.StatusFailed
|
||||||
StatusCancelled Status = "cancelled" // отменена пользователем
|
StatusTimeout = model.StatusTimeout
|
||||||
StatusAborted Status = "aborted" // сбой сбора, черновик выброшен
|
StatusCancelled = model.StatusCancelled
|
||||||
StatusClosed Status = "closed" // закрыта вручную
|
StatusAborted = model.StatusAborted
|
||||||
|
StatusClosed = model.StatusClosed
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllStatuses — все возможные статусы для валидации.
|
// AllStatuses — все возможные статусы для валидации.
|
||||||
var AllStatuses = []Status{
|
var AllStatuses = model.AllStatuses
|
||||||
StatusDraft, StatusCollecting, StatusReady,
|
|
||||||
StatusRunning, StatusSuccess, StatusFailed, StatusTimeout,
|
|
||||||
StatusCancelled, StatusAborted, StatusClosed,
|
|
||||||
}
|
|
||||||
|
|
||||||
// validTransitions задаёт разрешённые переходы статусов.
|
|
||||||
var validTransitions = map[Status][]Status{
|
|
||||||
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
|
|
||||||
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
|
|
||||||
StatusReady: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting}, // правка готового
|
|
||||||
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
|
|
||||||
StatusSuccess: {StatusClosed},
|
|
||||||
StatusFailed: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
|
|
||||||
StatusTimeout: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
|
|
||||||
StatusCancelled: {StatusClosed},
|
|
||||||
StatusAborted: {StatusClosed},
|
|
||||||
StatusClosed: {}, // терминальный
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValidTransition проверяет, допустим ли переход from → to.
|
// IsValidTransition проверяет, допустим ли переход from → to.
|
||||||
func IsValidTransition(from, to Status) bool {
|
func IsValidTransition(from, to Status) bool {
|
||||||
allowed, ok := validTransitions[from]
|
return model.IsValidTransition(from, to)
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, s := range allowed {
|
|
||||||
if s == to {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTerminal возвращает true, если статус терминальный.
|
// IsTerminal возвращает true, если статус терминальный.
|
||||||
func IsTerminal(s Status) bool {
|
func IsTerminal(s Status) bool {
|
||||||
return s == StatusSuccess || s == StatusCancelled ||
|
return model.IsTerminal(s)
|
||||||
s == StatusAborted || s == StatusClosed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task — запись задачи в БД.
|
// Task — запись задачи в БД.
|
||||||
@@ -107,14 +88,14 @@ func (t *Task) SetReposFromDB(repos string) {
|
|||||||
_ = json.Unmarshal([]byte(repos), &t.Repos)
|
_ = json.Unmarshal([]byte(repos), &t.Repos)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trace — запись трассировки выполнения.
|
// TraceStatus — алиас доменного статуса трассировки.
|
||||||
type TraceStatus string
|
type TraceStatus = model.TraceStatus
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TraceRunning TraceStatus = "running"
|
TraceRunning TraceStatus = model.TraceRunning
|
||||||
TraceSuccess TraceStatus = "success"
|
TraceSuccess TraceStatus = model.TraceSuccess
|
||||||
TraceFailed TraceStatus = "failed"
|
TraceFailed TraceStatus = model.TraceFailed
|
||||||
TraceTimeout TraceStatus = "timeout"
|
TraceTimeout TraceStatus = model.TraceTimeout
|
||||||
)
|
)
|
||||||
|
|
||||||
// Trace — лог одного субагента.
|
// Trace — лог одного субагента.
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ func TestIsValidTransition(t *testing.T) {
|
|||||||
{StatusTimeout, StatusReady, true}, // retry
|
{StatusTimeout, StatusReady, true}, // retry
|
||||||
{StatusTimeout, StatusCollecting, true}, // retry: перезапуск сбора
|
{StatusTimeout, StatusCollecting, true}, // retry: перезапуск сбора
|
||||||
{StatusTimeout, StatusRunning, false},
|
{StatusTimeout, StatusRunning, false},
|
||||||
|
{StatusReady, StatusApproved, true}, // «создавай» → одобрено
|
||||||
|
{StatusReady, StatusRunning, false}, // без одобрения воркер не запускает
|
||||||
|
{StatusApproved, StatusRunning, true}, // воркер берёт approved
|
||||||
|
{StatusApproved, StatusCancelled, true}, // отмена до запуска
|
||||||
|
{StatusApproved, StatusReady, false}, // финал: назад нельзя
|
||||||
|
{StatusApproved, StatusCollecting, false},
|
||||||
{StatusClosed, StatusDraft, false},
|
{StatusClosed, StatusDraft, false},
|
||||||
{StatusClosed, StatusRunning, false},
|
{StatusClosed, StatusRunning, false},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,10 +111,16 @@ func TestUpdateTaskStatus(t *testing.T) {
|
|||||||
t.Fatalf("UpdateTask collecting→ready: %v", err)
|
t.Fatalf("UpdateTask collecting→ready: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ready → running
|
// ready → approved
|
||||||
|
task.Status = StatusApproved
|
||||||
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("UpdateTask ready→approved: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// approved → running
|
||||||
task.Status = StatusRunning
|
task.Status = StatusRunning
|
||||||
if err := s.UpdateTask(ctx, task); err != nil {
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
t.Fatalf("UpdateTask ready→running: %v", err)
|
t.Fatalf("UpdateTask approved→running: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// running → success
|
// running → success
|
||||||
|
|||||||
39
internal/ui/chat_panel.go
Normal file
39
internal/ui/chat_panel.go
Normal 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 "🤖 "
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/ui/chat_panel_test.go
Normal file
26
internal/ui/chat_panel_test.go
Normal 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
46
internal/ui/commands.go
Normal 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
52
internal/ui/controller.go
Normal 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()
|
||||||
|
}
|
||||||
146
internal/ui/controller_test.go
Normal file
146
internal/ui/controller_test.go
Normal 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() // не должно падать
|
||||||
|
}
|
||||||
87
internal/ui/desktop/chat_panel.go
Normal file
87
internal/ui/desktop/chat_panel.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chatLimit — обрезка буфера транскрипта диалога (спец 12.12).
|
||||||
|
const chatLimit = 200_000
|
||||||
|
|
||||||
|
// ChatPanel — Fyne-реализация ui.ChatPanel (спец 12.8, 12.12): транскрипт
|
||||||
|
// диалога + композитор (поле ввода и кнопки команд).
|
||||||
|
type ChatPanel struct {
|
||||||
|
convLbl *widget.Label
|
||||||
|
input *widget.Entry
|
||||||
|
commands *ui.Commands
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatPanel создаёт ChatPanel.
|
||||||
|
func NewChatPanel() *ChatPanel {
|
||||||
|
p := &ChatPanel{}
|
||||||
|
p.convLbl = widget.NewLabel("Выберите задачу.")
|
||||||
|
p.convLbl.Wrapping = fyne.TextWrapWord
|
||||||
|
p.input = widget.NewEntry()
|
||||||
|
p.input.SetPlaceHolder("Сообщение… (Enter — отправить)")
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcript возвращает виджет транскрипта (Scroll) для встраивания в окно.
|
||||||
|
func (p *ChatPanel) Transcript() fyne.CanvasObject {
|
||||||
|
return container.NewScroll(p.convLbl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composer возвращает виджет композитора (ввод + кнопки команд).
|
||||||
|
func (p *ChatPanel) Composer() fyne.CanvasObject {
|
||||||
|
cmdBar := container.NewHBox(
|
||||||
|
widget.NewButton("Новая", func() { if p.commands != nil { p.commands.Start() } }),
|
||||||
|
widget.NewButton("Создавай", func() { if p.commands != nil { p.commands.Approve() } }),
|
||||||
|
widget.NewButton("Пропустить", func() { if p.commands != nil { p.commands.Skip() } }),
|
||||||
|
widget.NewButton("Отмена", func() { if p.commands != nil { p.commands.Cancel() } }),
|
||||||
|
)
|
||||||
|
return container.NewBorder(nil, nil, cmdBar, nil, p.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTranscript заменяет содержимое диалога (спец 12.12).
|
||||||
|
func (p *ChatPanel) SetTranscript(text string) {
|
||||||
|
p.convLbl.SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append добавляет строку в диалог (буфер ограничен).
|
||||||
|
func (p *ChatPanel) Append(text string) {
|
||||||
|
s := p.convLbl.Text + text + "\n\n"
|
||||||
|
if len(s) > chatLimit {
|
||||||
|
s = s[len(s)-chatLimit:]
|
||||||
|
}
|
||||||
|
p.convLbl.SetText(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear очищает диалог.
|
||||||
|
func (p *ChatPanel) Clear() {
|
||||||
|
p.convLbl.SetText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommands подключает команды к композитору.
|
||||||
|
func (p *ChatPanel) SetCommands(c *ui.Commands) {
|
||||||
|
p.commands = c
|
||||||
|
p.input.OnSubmitted = func(s string) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.input.SetText("")
|
||||||
|
if c != nil {
|
||||||
|
c.SendText(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile-time проверка реализации контракта.
|
||||||
|
var _ ui.ChatPanel = (*ChatPanel)(nil)
|
||||||
193
internal/ui/desktop/log_panel.go
Normal file
193
internal/ui/desktop/log_panel.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxLogBytes — кап суммарного объёма текста буфера «Логи» (байты), чтобы
|
||||||
|
// не расти бесконечно и не копить мегабайты в памяти.
|
||||||
|
const maxLogBytes = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// renderFlushMs — дебаунс-интервал перерисовки ленты (мс): при всплеске
|
||||||
|
// логов рендер выполняется не на каждую запись, а раз в этот интервал.
|
||||||
|
const renderFlushMs = 200
|
||||||
|
|
||||||
|
// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных
|
||||||
|
// логов с фильтром по уровням (error/warning/info/debug).
|
||||||
|
//
|
||||||
|
// Записи хранятся в ui.LogBuffer (ограниченный буфер); лента показывает
|
||||||
|
// только записи уровней, отмеченных в фильтре (по умолчанию отмечены все).
|
||||||
|
// Фильтр — выпадающий список с чекбоксами: кнопка над лентой открывает
|
||||||
|
// widget.PopUp с widget.Check по каждому уровню; изменение набора сразу
|
||||||
|
// перерисовывает ленту — и по уже загруженным записям, и для новых.
|
||||||
|
//
|
||||||
|
// Состояние (buf/selected/pending/flushScheduled/lastFlush) доступается только
|
||||||
|
// с потока Fyne: Append приходит из горутин Hub и заворачивается в fyne.Do,
|
||||||
|
// колбэки чекбоксов Fyne вызывает сам на своём потоке. Перерисовка ленты
|
||||||
|
// коалесцируется (см. scheduleFlush), т.к. строка ленты пересобирается
|
||||||
|
// целиком и при капе в 10MB на каждый append была бы дорогой.
|
||||||
|
type LogPanel struct {
|
||||||
|
label *widget.Label
|
||||||
|
btn *widget.Button // кнопка-дропдаун фильтра уровней
|
||||||
|
|
||||||
|
buf *ui.LogBuffer // буфер записей, старые вытесняются
|
||||||
|
selected map[string]bool // уровень → показывать (по умолчанию все)
|
||||||
|
|
||||||
|
pending bool // буфер изменился, рендер отложен (поток Fyne)
|
||||||
|
flushScheduled bool // фоновый флаш уже запущен (поток Fyne)
|
||||||
|
lastFlush int64 // мс последнего рендера (поток Fyne)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogPanel создаёт LogPanel с уровнями error/warning/info (debug скрыт).
|
||||||
|
func NewLogPanel() *LogPanel {
|
||||||
|
p := &LogPanel{
|
||||||
|
buf: ui.NewLogBuffer(maxLogBytes),
|
||||||
|
selected: make(map[string]bool, len(ui.LogLevels)),
|
||||||
|
}
|
||||||
|
for _, lvl := range ui.LogLevels {
|
||||||
|
p.selected[lvl] = lvl != ui.LogLevelDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
p.label = widget.NewLabel("")
|
||||||
|
p.label.Wrapping = fyne.TextWrapWord
|
||||||
|
p.btn = widget.NewButton(p.filterCaption(), p.showFilterPopUp)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget возвращает контейнер панели для встраивания в окно (таб «Логи»):
|
||||||
|
// строка фильтра сверху, лента лога в скролле снизу.
|
||||||
|
func (p *LogPanel) Widget() fyne.CanvasObject {
|
||||||
|
return container.NewBorder(
|
||||||
|
container.NewHBox(p.btn), nil, nil, nil,
|
||||||
|
container.NewScroll(p.label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append добавляет запись лога (спец 12.9), с ограничением буфера.
|
||||||
|
// Вызывается из горутин Hub → вся работа на потоке Fyne через fyne.Do.
|
||||||
|
func (p *LogPanel) Append(level, text string) {
|
||||||
|
fyne.Do(func() {
|
||||||
|
lvl := ui.NormalizeLogLevel(level)
|
||||||
|
dropped := p.buf.Append(lvl, text)
|
||||||
|
// Перерисовка нужна, если запись видима или вытеснение задело ленту.
|
||||||
|
if p.selected[lvl] || dropped {
|
||||||
|
p.pending = true
|
||||||
|
p.scheduleFlush()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleFlush планирует отложенный рендер через renderFlushMs (или сразу,
|
||||||
|
// если с прошлого рендера прошло больше интервала). Все вызовы — с потока
|
||||||
|
// Fyne; фоновый поток только спит и по таймеру отправляет flush через fyne.Do,
|
||||||
|
// поэтому защита от повторного планирования (flushScheduled) работает без
|
||||||
|
// блокировок.
|
||||||
|
func (p *LogPanel) scheduleFlush() {
|
||||||
|
if p.flushScheduled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.flushScheduled = true
|
||||||
|
delay := int64(renderFlushMs) - (time.Now().UnixMilli() - p.lastFlush)
|
||||||
|
if delay < 1 {
|
||||||
|
delay = 1
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||||
|
fyne.Do(p.flush)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush выполняет отложенную перерисовку ленты (на потоке Fyne).
|
||||||
|
func (p *LogPanel) flush() {
|
||||||
|
p.flushScheduled = false
|
||||||
|
if p.pending {
|
||||||
|
p.pending = false
|
||||||
|
p.lastFlush = time.Now().UnixMilli()
|
||||||
|
p.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// render перерисовывает ленту: дописывает в label только новые видимые
|
||||||
|
// записи (RenderNew). Если вытеснение задело уже выведенные записи (stale),
|
||||||
|
// RenderNew возвращает полный текст — пересобираем label целиком. Так при
|
||||||
|
// росте ленты мимо лимита флаш дешёвый (только дельта), а полная пересборка
|
||||||
|
// происходит лишь по вытеснению/смене фильтра.
|
||||||
|
func (p *LogPanel) render() {
|
||||||
|
delta, full := p.buf.RenderNew(p.selected)
|
||||||
|
if full {
|
||||||
|
p.label.SetText(delta)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if delta == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur := p.label.Text
|
||||||
|
if cur == "" {
|
||||||
|
p.label.SetText(delta)
|
||||||
|
} else {
|
||||||
|
p.label.SetText(cur + delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLevel включает/выключает показ уровня и сразу обновляет ленту.
|
||||||
|
// Вызывается только с потока Fyne (колбэки чекбоксов). Смена набора уровней
|
||||||
|
// меняет видимость уже выведенных записей, поэтому — полный пересбор.
|
||||||
|
func (p *LogPanel) setLevel(level string, on bool) {
|
||||||
|
if p.selected[level] == on {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.selected[level] = on
|
||||||
|
p.btn.SetText(p.filterCaption())
|
||||||
|
p.label.SetText(p.buf.Render(p.selected))
|
||||||
|
p.buf.ResetRendered()
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterCaption — подпись кнопки-дропдауна: текущий выбранный набор уровней.
|
||||||
|
func (p *LogPanel) filterCaption() string {
|
||||||
|
var chosen []string
|
||||||
|
for _, lvl := range ui.LogLevels {
|
||||||
|
if p.selected[lvl] {
|
||||||
|
chosen = append(chosen, lvl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(chosen) == len(ui.LogLevels):
|
||||||
|
return "Уровни: все"
|
||||||
|
case len(chosen) == 0:
|
||||||
|
return "Уровни: нет"
|
||||||
|
default:
|
||||||
|
return "Уровни: " + strings.Join(chosen, "+")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showFilterPopUp открывает выпадающий список с чекбоксами уровней под
|
||||||
|
// кнопкой фильтра. Список пересобирается при каждом открытии — чекбоксы
|
||||||
|
// всегда отражают актуальное состояние фильтра.
|
||||||
|
func (p *LogPanel) showFilterPopUp() {
|
||||||
|
c := fyne.CurrentApp().Driver().CanvasForObject(p.btn)
|
||||||
|
if c == nil {
|
||||||
|
return // виджет ещё не встроен в окно
|
||||||
|
}
|
||||||
|
rows := make([]fyne.CanvasObject, 0, len(ui.LogLevels))
|
||||||
|
for _, lvl := range ui.LogLevels {
|
||||||
|
lvl := lvl
|
||||||
|
cb := widget.NewCheck(lvl, func(on bool) { p.setLevel(lvl, on) })
|
||||||
|
cb.SetChecked(p.selected[lvl])
|
||||||
|
rows = append(rows, cb)
|
||||||
|
}
|
||||||
|
pop := widget.NewPopUp(container.NewPadded(container.NewVBox(rows...)), c)
|
||||||
|
pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(p.btn)
|
||||||
|
pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+p.btn.Size().Height))
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile-time проверка реализации контракта.
|
||||||
|
var _ ui.LogPanel = (*LogPanel)(nil)
|
||||||
48
internal/ui/desktop/maximize_windows.go
Normal file
48
internal/ui/desktop/maximize_windows.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Нативный максимум окна через Win32.
|
||||||
|
//
|
||||||
|
// Fyne (v2.6) не даёт ни Move, ни Maximize у fyne.Window, поэтому разворот на
|
||||||
|
// экран (над панелью задач) делаем напрямую через user32: находим HWND нашего
|
||||||
|
// процесса через EnumWindows и вызываем ShowWindow(SW_MAXIMIZE).
|
||||||
|
// Только Windows (платформа приложения по спеке — Windows).
|
||||||
|
|
||||||
|
const (
|
||||||
|
swMaximize = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
|
procEnumWindows = user32.NewProc("EnumWindows")
|
||||||
|
procGetWindowThreadProcID = user32.NewProc("GetWindowThreadProcessId")
|
||||||
|
procIsWindowVisible = user32.NewProc("IsWindowVisible")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
)
|
||||||
|
|
||||||
|
func maximizeWindow() {
|
||||||
|
pid := uint32(os.Getpid())
|
||||||
|
|
||||||
|
cb := syscall.NewCallback(func(hwnd syscall.Handle, _ uintptr) uintptr {
|
||||||
|
var winPid uint32
|
||||||
|
procGetWindowThreadProcID.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&winPid)))
|
||||||
|
if winPid != pid {
|
||||||
|
return 1 // continue перечисление
|
||||||
|
}
|
||||||
|
visible, _, _ := procIsWindowVisible.Call(uintptr(hwnd))
|
||||||
|
if visible == 0 {
|
||||||
|
return 1 // continue перечисление
|
||||||
|
}
|
||||||
|
procShowWindow.Call(uintptr(hwnd), swMaximize)
|
||||||
|
return 0 // остановить перечисление
|
||||||
|
})
|
||||||
|
|
||||||
|
procEnumWindows.Call(cb, 0)
|
||||||
|
}
|
||||||
50
internal/ui/desktop/state_panel.go
Normal file
50
internal/ui/desktop/state_panel.go
Normal 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)
|
||||||
54
internal/ui/desktop/task_detail_panel.go
Normal file
54
internal/ui/desktop/task_detail_panel.go
Normal 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)
|
||||||
78
internal/ui/desktop/task_list_panel.go
Normal file
78
internal/ui/desktop/task_list_panel.go
Normal 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)
|
||||||
452
internal/ui/desktop/window.go
Normal file
452
internal/ui/desktop/window.go
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
// Package desktop — Fyne-реализация окна UI (см. internal/ui).
|
||||||
|
//
|
||||||
|
// Сборка требует cgo (GLFW). На машинах без C-компилятора пакет не входит
|
||||||
|
// в сборку, и app работает в headless-режиме (newUIWindow → nil).
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
selectedPref = "task.selected"
|
||||||
|
splitHPref = "split.h"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Window — Fyne-окно: chat.Channel + ui.View.
|
||||||
|
//
|
||||||
|
// Внутри — вкладки-сессии (ui.Session): каждая вкладка — независимый диалог
|
||||||
|
// с LLM (свой UserID/Address, свой активный task). Свободные вкладки (без
|
||||||
|
// задачи) создаются кнопкой «Новый чат»; вкладки задач открываются из списка.
|
||||||
|
type Window struct {
|
||||||
|
fyneApp fyne.App
|
||||||
|
win fyne.Window
|
||||||
|
store ui.Store
|
||||||
|
|
||||||
|
handler chat.Handler
|
||||||
|
|
||||||
|
// состояние выбора (живёт в окне, спец 12.10)
|
||||||
|
selected int64
|
||||||
|
|
||||||
|
// виджеты
|
||||||
|
taskList *TaskListPanel
|
||||||
|
detail *TaskDetailPanel
|
||||||
|
state *StatePanel
|
||||||
|
logs *LogPanel
|
||||||
|
|
||||||
|
// вкладки-сессии
|
||||||
|
tabs *container.AppTabs
|
||||||
|
sessions map[chat.Address]*ui.Session // Address вкладки → сессия
|
||||||
|
panels map[chat.Address]*ChatPanel // Address вкладки → панель
|
||||||
|
tabSession map[*container.TabItem]*ui.Session
|
||||||
|
nextSess int // счётчик уникальных ui://sess-<n>
|
||||||
|
|
||||||
|
hsplit *container.Split
|
||||||
|
|
||||||
|
onQuit func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New создаёт окно. appID — идентификатор Fyne-приложения (Preferences).
|
||||||
|
func New(appID string, store ui.Store) *Window {
|
||||||
|
fa := app.NewWithID(appID)
|
||||||
|
w := &Window{
|
||||||
|
fyneApp: fa,
|
||||||
|
store: store,
|
||||||
|
sessions: map[chat.Address]*ui.Session{},
|
||||||
|
panels: map[chat.Address]*ChatPanel{},
|
||||||
|
tabSession: map[*container.TabItem]*ui.Session{},
|
||||||
|
}
|
||||||
|
w.win = fa.NewWindow("Ratatoskr")
|
||||||
|
w.build()
|
||||||
|
w.win.SetCloseIntercept(func() {
|
||||||
|
// Закрытие окна = сворачивание (спец 12.6): Core продолжает работать.
|
||||||
|
w.saveLayout()
|
||||||
|
w.win.Hide()
|
||||||
|
})
|
||||||
|
fa.Settings().SetTheme(theme.DarkTheme())
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnQuit задаёт колбэк полного выхода (кнопка «Завершить»).
|
||||||
|
func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn }
|
||||||
|
|
||||||
|
// build собирает виджеты и раскладку.
|
||||||
|
func (w *Window) build() {
|
||||||
|
w.detail = NewTaskDetailPanel()
|
||||||
|
w.state = NewStatePanel()
|
||||||
|
w.logs = NewLogPanel()
|
||||||
|
|
||||||
|
// Список задач (слева)
|
||||||
|
w.taskList = NewTaskListPanel()
|
||||||
|
w.taskList.OnSelect(func(id int64) { w.openTaskTab(id) })
|
||||||
|
|
||||||
|
quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() {
|
||||||
|
w.saveLayout()
|
||||||
|
if w.onQuit != nil {
|
||||||
|
w.onQuit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget())
|
||||||
|
|
||||||
|
// Центр: вкладки-сессии + кнопка «Новый чат».
|
||||||
|
w.tabs = container.NewAppTabs()
|
||||||
|
w.tabs.OnSelected = func(tab *container.TabItem) {
|
||||||
|
// фокус вкладки → показываем детали/состояние её задачи
|
||||||
|
if s := w.tabSession[tab]; s != nil && s.BoundTask > 0 {
|
||||||
|
w.selectTask(s.BoundTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newBtn := widget.NewButtonWithIcon("Новый чат", theme.ContentAddIcon(), func() {
|
||||||
|
w.addFreeTab()
|
||||||
|
})
|
||||||
|
center := container.NewBorder(newBtn, nil, nil, nil, w.tabs)
|
||||||
|
|
||||||
|
// Нижняя панель: вкладки «Логи» + «Состояние».
|
||||||
|
bottomTabs := container.NewAppTabs(
|
||||||
|
container.NewTabItem("Логи", w.logs.Widget()),
|
||||||
|
container.NewTabItem("Состояние", w.state.Widget()),
|
||||||
|
)
|
||||||
|
bottom := container.NewBorder(nil, nil, nil, nil, bottomTabs)
|
||||||
|
|
||||||
|
w.hsplit = container.NewHSplit(left, container.NewVSplit(center, bottom))
|
||||||
|
w.win.SetContent(w.hsplit)
|
||||||
|
|
||||||
|
// Восстановление layout из Preferences.
|
||||||
|
pref := w.fyneApp.Preferences()
|
||||||
|
w.hsplit.SetOffset(pref.FloatWithFallback(splitHPref, 0.30))
|
||||||
|
|
||||||
|
// Начальный снимок списка.
|
||||||
|
w.refreshList()
|
||||||
|
|
||||||
|
// Открываем свободную вкладку-сессию по умолчанию.
|
||||||
|
w.addFreeTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFreeTab создаёт свободную вкладку-сессию (in-memory диалог с LLM).
|
||||||
|
func (w *Window) addFreeTab() {
|
||||||
|
w.nextSess++
|
||||||
|
id := fmt.Sprintf("%s%d", ui.SessionID, w.nextSess)
|
||||||
|
w.addTab(ui.NewFreeSession(chat.UserID(id), chat.Address(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTab создаёт вкладку для сессии и фокусирует её.
|
||||||
|
func (w *Window) addTab(sess *ui.Session) {
|
||||||
|
panel := NewChatPanel()
|
||||||
|
panel.SetCommands(ui.NewCommands(func(text string) {
|
||||||
|
w.submitText(sess.UserID, sess.Address, text)
|
||||||
|
}))
|
||||||
|
|
||||||
|
content := container.NewBorder(nil, panel.Composer(), nil, nil, panel.Transcript())
|
||||||
|
tab := container.NewTabItem(sess.Title, content)
|
||||||
|
w.tabs.Append(tab)
|
||||||
|
|
||||||
|
w.sessions[sess.Address] = sess
|
||||||
|
w.panels[sess.Address] = panel
|
||||||
|
w.tabSession[tab] = sess
|
||||||
|
|
||||||
|
w.tabs.Select(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tabForAddress возвращает вкладку по адресу сессии.
|
||||||
|
func (w *Window) tabForAddress(addr chat.Address) *container.TabItem {
|
||||||
|
for tab, s := range w.tabSession {
|
||||||
|
if s.Address == addr {
|
||||||
|
return tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openTaskTab открывает вкладку задачи (или фокусирует уже открытую).
|
||||||
|
//
|
||||||
|
// Вкладку можно открыть только для задачи, созданной в UI-сессии (ChatID с
|
||||||
|
// префиксом ui://sess-*): её ChatID уникален и не пересекается с Telegram-каналом.
|
||||||
|
// Задачи Telegram не открываются в отдельной вкладке — их показывает список и
|
||||||
|
// детали (selectTask), чтобы не плодить сессию с общим UserID=чат-id.
|
||||||
|
func (w *Window) openTaskTab(id int64) {
|
||||||
|
ctx := context.Background()
|
||||||
|
t, err := w.store.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram-задача (ChatID без ui://sess-*) — только детали, без вкладки.
|
||||||
|
if !strings.HasPrefix(t.ChatID, ui.SessionID) {
|
||||||
|
w.selectTask(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если вкладка для задачи уже есть — фокусируем.
|
||||||
|
for _, s := range w.sessions {
|
||||||
|
if s.BoundTask == id {
|
||||||
|
if tab := w.tabForAddress(s.Address); tab != nil {
|
||||||
|
w.tabs.Select(tab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём новую вкладку: UserID == task.ChatID (чат задачи уникален).
|
||||||
|
w.nextSess++
|
||||||
|
addr := chat.Address(fmt.Sprintf("%s%d", ui.SessionID, w.nextSess))
|
||||||
|
sess := ui.NewTaskSession(addr, t)
|
||||||
|
w.addTab(sess)
|
||||||
|
|
||||||
|
// Транскрипт из истории задачи.
|
||||||
|
if panel := w.panels[sess.Address]; panel != nil {
|
||||||
|
panel.SetTranscript(w.renderHistory(ctx, id))
|
||||||
|
}
|
||||||
|
w.selectTask(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHistory строит транскрипт диалога задачи (история + краткие трассы).
|
||||||
|
func (w *Window) renderHistory(ctx context.Context, id int64) string {
|
||||||
|
hist, err := w.store.GetHistory(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
hist = nil
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, h := range hist {
|
||||||
|
b.WriteString(ui.FormatRole(h.Role))
|
||||||
|
b.WriteString(h.Content)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
traces, err := w.store.GetTraces(ctx, id)
|
||||||
|
if err == nil {
|
||||||
|
for _, tr := range traces {
|
||||||
|
b.WriteString("— " + tr.Agent + " (" + string(tr.Status) + ")\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
b.WriteString("Нет сообщений. /start — начать задачу.")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveLayout сохраняет текущие позиции сплитов в Preferences.
|
||||||
|
// Вызывается при сворачивании окна и перед полным выходом.
|
||||||
|
func (w *Window) saveLayout() {
|
||||||
|
pref := w.fyneApp.Preferences()
|
||||||
|
pref.SetFloat(splitHPref, w.hsplit.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectTask загружает детали выбранной задачи (снимки из Store).
|
||||||
|
func (w *Window) selectTask(id int64) {
|
||||||
|
w.selected = id
|
||||||
|
w.taskList.Select(id)
|
||||||
|
w.fyneApp.Preferences().SetInt(selectedPref, int(id))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
t, err := w.store.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
w.detail.ShowEmpty()
|
||||||
|
w.state.SetText(ui.StateText(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.detail.ShowTask(t)
|
||||||
|
w.refreshState(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshState — снимок «Состояния» выбранной задачи (агенты + трейсы).
|
||||||
|
func (w *Window) refreshState(ctx context.Context, id int64) {
|
||||||
|
traces, err := w.store.GetTraces(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.state.SetText(ui.StateText(traces))
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshList перечитывает список задач из Store (снимок).
|
||||||
|
func (w *Window) refreshList() {
|
||||||
|
ctx := context.Background()
|
||||||
|
tasks, err := w.store.ListTasks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ui: list tasks: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.taskList.SetTasks(tasks)
|
||||||
|
w.taskList.Select(w.selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendLog добавляет запись в панель «Логи» (делегирует в LogPanel).
|
||||||
|
func (w *Window) appendLog(level, text string) {
|
||||||
|
w.logs.Append(level, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitText отправляет ввод пользователя из вкладки в Router.
|
||||||
|
func (w *Window) submitText(uid chat.UserID, addr chat.Address, text string) {
|
||||||
|
if w.handler != nil {
|
||||||
|
w.handler(chat.Incoming{
|
||||||
|
UserID: uid,
|
||||||
|
Address: addr,
|
||||||
|
Channel: w,
|
||||||
|
Msg: chat.Message{Text: text},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindTaskSession связывает свободную вкладку с созданной задачей: когда
|
||||||
|
// /start в свободном табе создал задачу с ChatID == UserID этой вкладки,
|
||||||
|
// вкладка становится task-сессией (спец: каждая задача — своя вкладка).
|
||||||
|
func (w *Window) bindTaskSession(chatID string, taskID int64) {
|
||||||
|
for addr, s := range w.sessions {
|
||||||
|
if string(s.UserID) != chatID || s.BoundTask != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.BoundTask = taskID
|
||||||
|
if tab := w.tabForAddress(addr); tab != nil {
|
||||||
|
tab.Text = "Задача #" + ui.Itoa(taskID)
|
||||||
|
w.tabs.Refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- chat.Channel ----
|
||||||
|
|
||||||
|
// OnMessage регистрирует обработчик входящих (Router).
|
||||||
|
func (w *Window) OnMessage(h chat.Handler) { w.handler = h }
|
||||||
|
|
||||||
|
// Run показывает окно и запускает Fyne-цикл. Блокирует до Quit.
|
||||||
|
func (w *Window) Run(ctx context.Context) error {
|
||||||
|
w.win.Show()
|
||||||
|
maximizeWindow()
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
w.fyneApp.Quit()
|
||||||
|
}()
|
||||||
|
w.fyneApp.Run()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send доставляет сообщение Core в диалог вкладки по её Address.
|
||||||
|
func (w *Window) Send(_ context.Context, to chat.Address, m chat.Message) error {
|
||||||
|
panel, ok := w.panels[to]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text := m.Text
|
||||||
|
if len(m.Options) > 0 {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(text)
|
||||||
|
for _, opt := range m.Options {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(opt.Label)
|
||||||
|
}
|
||||||
|
text = sb.String()
|
||||||
|
}
|
||||||
|
fyne.Do(func() { panel.Append("🤖 " + text) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask — вопрос с вариантами (отображается в диалоге вкладки).
|
||||||
|
func (w *Window) Ask(_ context.Context, to chat.Address, m chat.Message) error {
|
||||||
|
panel, ok := w.panels[to]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text := m.Text
|
||||||
|
for _, opt := range m.Options {
|
||||||
|
text += "\n" + opt.Label
|
||||||
|
}
|
||||||
|
fyne.Do(func() { panel.Append("❓ " + text) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close завершает Fyne-цикл.
|
||||||
|
func (w *Window) Close() error {
|
||||||
|
w.fyneApp.Quit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ui.View ----
|
||||||
|
|
||||||
|
// Все колбэки выполняются в горутинах events.Hub → обновляем UI через fyne.Do.
|
||||||
|
|
||||||
|
func (w *Window) OnTaskCreated(e events.TaskCreated) {
|
||||||
|
fyne.Do(func() {
|
||||||
|
w.refreshList()
|
||||||
|
// /start в свободной вкладке: вкладка привязывается к задаче.
|
||||||
|
w.bindTaskSession(e.ChatID, e.ID)
|
||||||
|
if w.selected == 0 {
|
||||||
|
w.selectTask(e.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) OnTaskUpdated(e events.TaskUpdated) {
|
||||||
|
fyne.Do(func() {
|
||||||
|
w.refreshList()
|
||||||
|
if w.selected == e.ID {
|
||||||
|
w.selectTask(e.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) OnTaskDeleted(e events.TaskDeleted) {
|
||||||
|
fyne.Do(func() {
|
||||||
|
if w.selected == e.ID {
|
||||||
|
w.selected = 0
|
||||||
|
}
|
||||||
|
w.refreshList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) {
|
||||||
|
fyne.Do(func() {
|
||||||
|
w.refreshList()
|
||||||
|
if w.selected == e.ID {
|
||||||
|
w.selectTask(e.ID)
|
||||||
|
}
|
||||||
|
w.state.Append(ui.TaskStatusText(e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) OnHistoryAppended(e events.HistoryAppended) {
|
||||||
|
fyne.Do(func() {
|
||||||
|
// история приходит в диалог вкладки задачи
|
||||||
|
for addr, s := range w.sessions {
|
||||||
|
if s.BoundTask == e.TaskID {
|
||||||
|
if panel, ok := w.panels[addr]; ok {
|
||||||
|
panel.Append(ui.FormatRole(e.Role) + e.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) OnTraceAppended(e events.TraceAppended) {
|
||||||
|
fyne.Do(func() {
|
||||||
|
w.state.Append(ui.TraceLine(e.Agent, e.Status))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) OnAgentActivity(e events.AgentActivity) {
|
||||||
|
fyne.Do(func() {
|
||||||
|
w.state.Append(ui.ActivityLine(e.Agent, e.Stage))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) OnLog(e events.LogLine) {
|
||||||
|
w.appendLog(e.Level, e.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile-time проверка: окно реализует chat.Channel и ui.View.
|
||||||
|
var (
|
||||||
|
_ chat.Channel = (*Window)(nil)
|
||||||
|
_ ui.View = (*Window)(nil)
|
||||||
|
)
|
||||||
226
internal/ui/log_panel.go
Normal file
226
internal/ui/log_panel.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// Package ui — контроллер и представления десктопного интерфейса (Fyne).
|
||||||
|
//
|
||||||
|
// Слой обмена с ядром: однонаправленный поток (спец 12.2–12.5).
|
||||||
|
// - Core мутирует состояние; UI только читает снимки и реагирует на события.
|
||||||
|
// - Действия UI = команды (CreateTask/ApproveTask/...), которые зовут Core.
|
||||||
|
// - Подписчик шины (Controller) получает события и перекладывает их в View
|
||||||
|
// (реализация Fyne) через fyne.Do — никаких прямых вызовов Fyne из core.
|
||||||
|
//
|
||||||
|
// Контракты компонентов-виджетов (спец 12.8): интерфейс + Nil-реализация.
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Канонические уровни записей лога для фильтра панели «Логи» (спец 12.9).
|
||||||
|
const (
|
||||||
|
LogLevelError = "error"
|
||||||
|
LogLevelWarning = "warning"
|
||||||
|
LogLevelInfo = "info"
|
||||||
|
LogLevelDebug = "debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogLevels — уровни в порядке их вывода в фильтре виджета «Логи».
|
||||||
|
var LogLevels = []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
|
||||||
|
|
||||||
|
// NormalizeLogLevel приводит уровень записи лога к одному из канонических
|
||||||
|
// уровней (LogLevels). Синонимы маппятся на ближайший уровень (err/fatal →
|
||||||
|
// error, warn → warning, trace → debug); пустые и неизвестные значения
|
||||||
|
// ("log" от events.LogWriter и всё прочее) считаются информационными.
|
||||||
|
func NormalizeLogLevel(level string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case LogLevelError, "err", "fatal":
|
||||||
|
return LogLevelError
|
||||||
|
case LogLevelWarning, "warn":
|
||||||
|
return LogLevelWarning
|
||||||
|
case LogLevelDebug, "trace":
|
||||||
|
return LogLevelDebug
|
||||||
|
default:
|
||||||
|
return LogLevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogPanel — панель «Логи» (спец 12.9): поток системных логов с уровнями.
|
||||||
|
//
|
||||||
|
// Хранит записи (уровень + строка) в буфере ограниченного размера; при
|
||||||
|
// превышении лимита старые записи отбрасываются. Реализация обязана
|
||||||
|
// поддерживать фильтр по уровням: выпадающий список с чекбоксами
|
||||||
|
// (error/warning/info/debug), по умолчанию отмечены все уровни; показываются
|
||||||
|
// только записи выбранных уровней, изменение набора сразу обновляет ленту.
|
||||||
|
// Контейнер виджета отдаётся окну для встраивания в таб «Логи» — реализация
|
||||||
|
// (Fyne) сама владеет своим виджетом.
|
||||||
|
type LogPanel interface {
|
||||||
|
// Append добавляет запись лога: level — уровень из events.LogLine
|
||||||
|
// (нормализуется через NormalizeLogLevel), text — строка лога.
|
||||||
|
// Реализация обязана перекладывать работу на поток Fyne (fyne.Do /
|
||||||
|
// fyne.DoAndWait), т.к. вызывается из горутин Hub.
|
||||||
|
Append(level, text string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NilLogPanel — no-op реализация LogPanel для headless-режима и тестов.
|
||||||
|
type NilLogPanel struct{}
|
||||||
|
|
||||||
|
// NewNilLogPanel создаёт NilLogPanel.
|
||||||
|
func NewNilLogPanel() *NilLogPanel { return &NilLogPanel{} }
|
||||||
|
|
||||||
|
func (NilLogPanel) Append(string, string) {}
|
||||||
|
|
||||||
|
// LogEntry — запись буфера панели «Логи»: канонический уровень + строка.
|
||||||
|
type LogEntry struct {
|
||||||
|
Level string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// compactThreshold — порог, при котором «мёртвые» (вытесненные сдвигом)
|
||||||
|
// записи физически убираются из буфера пачкой. Вытеснение лениво: оно лишь
|
||||||
|
// инкрементирует start, поэтому не копирует хвост массива на каждую
|
||||||
|
// вытесненную запись (иначе trim был бы O(n²)). Уборка выполняется редко.
|
||||||
|
const compactThreshold = 256
|
||||||
|
|
||||||
|
// LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням
|
||||||
|
// (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией
|
||||||
|
// LogPanel и тестируется в internal/ui.
|
||||||
|
//
|
||||||
|
// Не синхронизирована: потокобезопасность обеспечивает владелец (все вызовы
|
||||||
|
// на потоке Fyne).
|
||||||
|
type LogBuffer struct {
|
||||||
|
entries []LogEntry
|
||||||
|
start int // индекс первого «живого» элемента (ленивое вытеснение)
|
||||||
|
total int // суммарный объём текста живых записей [start..], байты
|
||||||
|
maxLen int // лимит суммарного объёма текста записей, байты
|
||||||
|
|
||||||
|
// Инкрементальная отрисовка (RenderNew): rendered — сколько живых записей
|
||||||
|
// от начала уже выведено в текст ленты; stale — вытеснение задело уже
|
||||||
|
// выведенные записи, так что текст ленты нельзя дополнить дельтом и нужен
|
||||||
|
// полный пересбор. Эти поля поддерживаются только рендером.
|
||||||
|
rendered int
|
||||||
|
stale bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogBuffer создаёт буфер ёмкостью maxLen байт текста записей.
|
||||||
|
func NewLogBuffer(maxLen int) *LogBuffer {
|
||||||
|
return &LogBuffer{maxLen: maxLen}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append добавляет запись (уровень нормализуется через NormalizeLogLevel,
|
||||||
|
// хвостовые переводы строк срезаются). При превышении лимита старые записи
|
||||||
|
// вытесняются. Сообщает, было ли вытеснение/обрезка — владелец должен
|
||||||
|
// перерисовать ленту, даже если новая запись не видима.
|
||||||
|
func (b *LogBuffer) Append(level, text string) bool {
|
||||||
|
b.entries = append(b.entries, LogEntry{
|
||||||
|
Level: NormalizeLogLevel(level),
|
||||||
|
Text: strings.TrimRight(text, "\r\n"),
|
||||||
|
})
|
||||||
|
b.total += len(b.entries[len(b.entries)-1].Text) + 1
|
||||||
|
return b.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim удерживает суммарный объём текста живых записей в пределах maxLen,
|
||||||
|
// лениво вытесняя старые (инкремент start); одиночная запись длиннее лимита
|
||||||
|
// обрезается до последних maxLen байт. Сообщает, было ли вытеснение/обрезка.
|
||||||
|
func (b *LogBuffer) trim() bool {
|
||||||
|
dropped := false
|
||||||
|
live := len(b.entries) - b.start
|
||||||
|
evicted := 0
|
||||||
|
for b.total > b.maxLen && live > 1 {
|
||||||
|
b.total -= len(b.entries[b.start].Text) + 1
|
||||||
|
b.start++
|
||||||
|
live--
|
||||||
|
evicted++
|
||||||
|
dropped = true
|
||||||
|
}
|
||||||
|
// Если вытеснена хотя бы одна уже выведенная запись (b.rendered), текст
|
||||||
|
// ленты устарел: дельта не может убрать верхние строки, нужен полный
|
||||||
|
// пересбор. rendered при этом уменьшается на число вытесненных.
|
||||||
|
if evicted > 0 && b.rendered > 0 {
|
||||||
|
b.stale = true
|
||||||
|
}
|
||||||
|
if evicted >= b.rendered {
|
||||||
|
b.rendered = 0
|
||||||
|
} else {
|
||||||
|
b.rendered -= evicted
|
||||||
|
}
|
||||||
|
// Компакция «мёртвых» записей пачкой, а не на каждом append.
|
||||||
|
if b.start >= compactThreshold {
|
||||||
|
b.entries = b.entries[b.start:]
|
||||||
|
b.start = 0
|
||||||
|
}
|
||||||
|
if n := len(b.entries); n > b.start && len(b.entries[n-1].Text) > b.maxLen {
|
||||||
|
// Обрезка последней записи меняет уже выведенный текст, если она
|
||||||
|
// была отрисована.
|
||||||
|
if b.rendered == n-b.start {
|
||||||
|
b.stale = true
|
||||||
|
}
|
||||||
|
b.entries[n-1].Text = b.entries[n-1].Text[len(b.entries[n-1].Text)-b.maxLen:]
|
||||||
|
b.total = b.maxLen + 1
|
||||||
|
dropped = true
|
||||||
|
}
|
||||||
|
return dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries возвращает копию списка живых записей буфера.
|
||||||
|
func (b *LogBuffer) Entries() []LogEntry {
|
||||||
|
out := make([]LogEntry, len(b.entries)-b.start)
|
||||||
|
copy(out, b.entries[b.start:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render строит текст ленты только из живых записей уровней, отмеченных в
|
||||||
|
// selected (уровень → показывать). Пустой или nil набор → пустая лента.
|
||||||
|
// Полный рендер: используется для первичной отрисовки и после смены фильтра.
|
||||||
|
func (b *LogBuffer) Render(selected map[string]bool) string {
|
||||||
|
if selected == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return b.renderFrom(b.start, selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetRendered помечает все живые записи как уже выведенные. Зовётся после
|
||||||
|
// полного рендера (SetText(Render)), чтобы следующий RenderNew вернул только
|
||||||
|
// новые записи.
|
||||||
|
func (b *LogBuffer) ResetRendered() {
|
||||||
|
b.rendered = len(b.entries) - b.start
|
||||||
|
b.stale = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderNew отдаёт текст ещё не выведенных видимых записей и флаг, требует
|
||||||
|
// ли слой полного пересбора ленты. Если вытеснение задело уже выведенные
|
||||||
|
// записи (stale), возвращаемый текст — полный рендер с нуля (и флаг true), а
|
||||||
|
// не дельта. Иначе — только текст новых записей, которым можно дополнить
|
||||||
|
// текущий текст ленты.
|
||||||
|
func (b *LogBuffer) RenderNew(selected map[string]bool) (text string, full bool) {
|
||||||
|
if selected == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
live := len(b.entries) - b.start
|
||||||
|
if b.stale {
|
||||||
|
b.stale = false
|
||||||
|
b.rendered = live
|
||||||
|
return b.renderFrom(b.start, selected), true
|
||||||
|
}
|
||||||
|
from := b.start + b.rendered
|
||||||
|
var s strings.Builder
|
||||||
|
for i := from; i < len(b.entries); i++ {
|
||||||
|
e := b.entries[i]
|
||||||
|
if !selected[e.Level] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.WriteString(e.Text)
|
||||||
|
s.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.rendered = live
|
||||||
|
return s.String(), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderFrom строит текст записей, начиная с индекса from, с учётом selected.
|
||||||
|
func (b *LogBuffer) renderFrom(from int, selected map[string]bool) string {
|
||||||
|
var s strings.Builder
|
||||||
|
for i := from; i < len(b.entries); i++ {
|
||||||
|
e := b.entries[i]
|
||||||
|
if !selected[e.Level] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.WriteString(e.Text)
|
||||||
|
s.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return s.String()
|
||||||
|
}
|
||||||
320
internal/ui/log_panel_test.go
Normal file
320
internal/ui/log_panel_test.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// NilLogPanel — no-op контракт (спец 12.8): Append ничего не делает, безопасно
|
||||||
|
// вызывается из горутин Hub и из тестов без Fyne.
|
||||||
|
func TestNilLogPanelAppendNoop(t *testing.T) {
|
||||||
|
p := NewNilLogPanel()
|
||||||
|
p.Append(LogLevelError, "line 1")
|
||||||
|
p.Append("", "")
|
||||||
|
p.Append(LogLevelDebug, "line 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeLogLevel приводит любые уровни к каноническим четырём:
|
||||||
|
// синонимы маппятся, неизвестные/пустые значения считаются info.
|
||||||
|
func TestNormalizeLogLevel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{LogLevelError, LogLevelError},
|
||||||
|
{"ERROR", LogLevelError},
|
||||||
|
{" err ", LogLevelError},
|
||||||
|
{"fatal", LogLevelError},
|
||||||
|
{LogLevelWarning, LogLevelWarning},
|
||||||
|
{"WARN", LogLevelWarning},
|
||||||
|
{"Warning", LogLevelWarning},
|
||||||
|
{LogLevelInfo, LogLevelInfo},
|
||||||
|
{"INFO", LogLevelInfo},
|
||||||
|
{LogLevelDebug, LogLevelDebug},
|
||||||
|
{"DEBUG", LogLevelDebug},
|
||||||
|
{"trace", LogLevelDebug},
|
||||||
|
// события LogWriter без явного уровня и прочий мусор → info
|
||||||
|
{"log", LogLevelInfo},
|
||||||
|
{"", LogLevelInfo},
|
||||||
|
{" ", LogLevelInfo},
|
||||||
|
{"unknown", LogLevelInfo},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := NormalizeLogLevel(c.in); got != c.want {
|
||||||
|
t.Errorf("NormalizeLogLevel(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogLevels — ровно четыре уровня фильтра в фиксированном порядке.
|
||||||
|
func TestLogLevelsContract(t *testing.T) {
|
||||||
|
want := []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
|
||||||
|
if len(LogLevels) != len(want) {
|
||||||
|
t.Fatalf("len(LogLevels) = %d, want %d", len(LogLevels), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if LogLevels[i] != want[i] {
|
||||||
|
t.Fatalf("LogLevels = %v, want %v", LogLevels, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allLevels — набор «отмечены все уровни» (состояние фильтра по умолчанию).
|
||||||
|
func allLevels() map[string]bool {
|
||||||
|
m := make(map[string]bool, len(LogLevels))
|
||||||
|
for _, lvl := range LogLevels {
|
||||||
|
m[lvl] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// По умолчанию (все уровни отмечены) рендерится полный лог; уровни
|
||||||
|
// нормализуются при добавлении.
|
||||||
|
func TestLogBufferRenderAllByDefault(t *testing.T) {
|
||||||
|
b := NewLogBuffer(1000)
|
||||||
|
b.Append("error", "e1")
|
||||||
|
b.Append("warn", "w1") // синоним → warning
|
||||||
|
b.Append("log", "i1") // неизвестный уровень → info
|
||||||
|
b.Append("trace", "d1") // синоним → debug
|
||||||
|
|
||||||
|
got := b.Render(allLevels())
|
||||||
|
want := "e1\nw1\ni1\nd1\n"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("Render(all) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := b.Entries()
|
||||||
|
wantLvls := []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Level != wantLvls[i] {
|
||||||
|
t.Fatalf("entry[%d].Level = %q, want %q", i, e.Level, wantLvls[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываются только записи выбранных уровней.
|
||||||
|
func TestLogBufferRenderSelectedOnly(t *testing.T) {
|
||||||
|
b := NewLogBuffer(1000)
|
||||||
|
b.Append(LogLevelError, "err line")
|
||||||
|
b.Append(LogLevelWarning, "warn line")
|
||||||
|
b.Append(LogLevelInfo, "info line")
|
||||||
|
b.Append(LogLevelDebug, "debug line")
|
||||||
|
|
||||||
|
onlyErrors := map[string]bool{LogLevelError: true}
|
||||||
|
if got, want := b.Render(onlyErrors), "err line\n"; got != want {
|
||||||
|
t.Fatalf("Render(error only) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
errorsAndDebug := map[string]bool{LogLevelError: true, LogLevelDebug: true}
|
||||||
|
if got, want := b.Render(errorsAndDebug), "err line\ndebug line\n"; got != want {
|
||||||
|
t.Fatalf("Render(error+debug) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ни один уровень не выбран — лента пуста (и это не ошибка).
|
||||||
|
func TestLogBufferRenderEmptySelection(t *testing.T) {
|
||||||
|
b := NewLogBuffer(1000)
|
||||||
|
b.Append(LogLevelError, "err line")
|
||||||
|
b.Append(LogLevelInfo, "info line")
|
||||||
|
|
||||||
|
if got := b.Render(map[string]bool{}); got != "" {
|
||||||
|
t.Fatalf("Render(none) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := b.Render(nil); got != "" {
|
||||||
|
t.Fatalf("Render(nil) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация применяется и к уже загруженным записям: смена набора уровней
|
||||||
|
// меняет результат рендера того же буфера без новых Append.
|
||||||
|
func TestLogBufferRefilterLoadedEntries(t *testing.T) {
|
||||||
|
b := NewLogBuffer(1000)
|
||||||
|
b.Append(LogLevelError, "err line")
|
||||||
|
b.Append(LogLevelInfo, "info line")
|
||||||
|
|
||||||
|
all := allLevels()
|
||||||
|
if got, want := b.Render(all), "err line\ninfo line\n"; got != want {
|
||||||
|
t.Fatalf("Render(all) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
delete(all, LogLevelInfo)
|
||||||
|
if got, want := b.Render(all), "err line\n"; got != want {
|
||||||
|
t.Fatalf("Render(error) after refilter = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
all[LogLevelInfo] = true // вернули галочку — запись снова видима
|
||||||
|
if got, want := b.Render(all), "err line\ninfo line\n"; got != want {
|
||||||
|
t.Fatalf("Render(all) after re-check = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новые записи сразу попадают в ленту своего уровня.
|
||||||
|
func TestLogBufferNewEntriesVisibleImmediately(t *testing.T) {
|
||||||
|
b := NewLogBuffer(1000)
|
||||||
|
sel := map[string]bool{LogLevelError: true}
|
||||||
|
|
||||||
|
b.Append(LogLevelError, "first")
|
||||||
|
if got, want := b.Render(sel), "first\n"; got != want {
|
||||||
|
t.Fatalf("Render = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Append(LogLevelInfo, "noise") // невидимый уровень — ленту не меняет
|
||||||
|
if got, want := b.Render(sel), "first\n"; got != want {
|
||||||
|
t.Fatalf("Render after invisible append = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Append(LogLevelError, "second")
|
||||||
|
if got, want := b.Render(sel), "first\nsecond\n"; got != want {
|
||||||
|
t.Fatalf("Render after visible append = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Буфер ограничен: старые записи вытесняются, суммарный объём ≤ лимита.
|
||||||
|
func TestLogBufferBounded(t *testing.T) {
|
||||||
|
b := NewLogBuffer(10)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
b.Append(LogLevelInfo, "0123456789") // каждая запись — ровно лимит
|
||||||
|
}
|
||||||
|
entries := b.Entries()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("len(entries) = %d, want 1 (старые вытеснены)", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].Text != "0123456789" {
|
||||||
|
t.Fatalf("text = %q", entries[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// одиночная запись длиннее лимита обрезается до последних maxLen символов
|
||||||
|
b2 := NewLogBuffer(4)
|
||||||
|
b2.Append(LogLevelInfo, "abcdefgh")
|
||||||
|
if got := b2.Entries()[0].Text; got != "efgh" {
|
||||||
|
t.Fatalf("oversized entry text = %q, want %q", got, "efgh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Буфер держит только живые записи: ленивое вытеснение (start) не даёт
|
||||||
|
// «мёртвым» записям попасть в Entries/Render.
|
||||||
|
func TestLogBufferLiveEntriesOnly(t *testing.T) {
|
||||||
|
b := NewLogBuffer(30)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
b.Append(LogLevelInfo, "0123456789") // 10 байт + \n каждая
|
||||||
|
}
|
||||||
|
// сумма 33 > 30 → вытесняется первая запись, остаются две живые
|
||||||
|
entries := b.Entries()
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("len(Entries) = %d, want 2 (мёртвая запись скрыта)", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].Text != "0123456789" || entries[1].Text != "0123456789" {
|
||||||
|
t.Fatalf("unexpected live entries: %+v", entries)
|
||||||
|
}
|
||||||
|
if got, want := b.Render(allLevels()), "0123456789\n0123456789\n"; got != want {
|
||||||
|
t.Fatalf("Render = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кап считается по байтам текста, а не по числу записей: короткие записи
|
||||||
|
// не вытесняются, пока их суммарный объём не превысит лимит.
|
||||||
|
func TestLogBufferBytesCap(t *testing.T) {
|
||||||
|
b := NewLogBuffer(100)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
b.Append(LogLevelInfo, "hello") // 5 байт + \n
|
||||||
|
}
|
||||||
|
if got := len(b.Entries()); got != 3 {
|
||||||
|
t.Fatalf("len(Entries) = %d, want 3 (все помещаются)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// одна большая запись вытесняет всё старое (live=1) и обрезается
|
||||||
|
b2 := NewLogBuffer(8)
|
||||||
|
b2.Append(LogLevelInfo, "short")
|
||||||
|
b2.Append(LogLevelInfo, "verylongtext")
|
||||||
|
entries := b2.Entries()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("len(Entries) = %d, want 1", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].Text != "longtext" {
|
||||||
|
t.Fatalf("oversized entry text = %q, want %q", entries[0].Text, "longtext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первый RenderNew отдаёт весь видимый текст, последующие — только новые
|
||||||
|
// записи (инкрементальная отрисовка: повторный вызов без Append пуст).
|
||||||
|
func TestLogBufferRenderNewDelta(t *testing.T) {
|
||||||
|
b := NewLogBuffer(1000)
|
||||||
|
sel := allLevels()
|
||||||
|
|
||||||
|
b.Append(LogLevelError, "e1")
|
||||||
|
b.Append(LogLevelInfo, "i1")
|
||||||
|
text, full := b.RenderNew(sel)
|
||||||
|
if full {
|
||||||
|
t.Fatalf("первый RenderNew не должен требовать полного пересбора, got full")
|
||||||
|
}
|
||||||
|
if text != "e1\ni1\n" {
|
||||||
|
t.Fatalf("первый RenderNew = %q, want %q", text, "e1\ni1\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без новых записей — пустой дельта.
|
||||||
|
text, full = b.RenderNew(sel)
|
||||||
|
if full || text != "" {
|
||||||
|
t.Fatalf("повторный RenderNew без новых записей = (%q, %v), want (\"\", false)", text, full)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новые записи — только их текст, без повторной отдачи старых.
|
||||||
|
b.Append(LogLevelInfo, "i2")
|
||||||
|
text, _ = b.RenderNew(sel)
|
||||||
|
if text != "i2\n" {
|
||||||
|
t.Fatalf("дельта RenderNew = %q, want %q", text, "i2\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderNew применяет фильтр: невидимые уровни не попадают в дельту.
|
||||||
|
func TestLogBufferRenderNewFiltered(t *testing.T) {
|
||||||
|
b := NewLogBuffer(1000)
|
||||||
|
sel := map[string]bool{LogLevelError: true}
|
||||||
|
b.Append(LogLevelError, "e1")
|
||||||
|
b.Append(LogLevelInfo, "i1")
|
||||||
|
b.Append(LogLevelError, "e2")
|
||||||
|
|
||||||
|
text, _ := b.RenderNew(sel)
|
||||||
|
if text != "e1\ne2\n" {
|
||||||
|
t.Fatalf("RenderNew(error only) = %q, want %q", text, "e1\ne2\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderNew уважает ResetRendered: после полного рендера новые записи
|
||||||
|
// добавляются дельтом, а не вытесняют уже выведенный текст.
|
||||||
|
func TestLogBufferRenderNewAfterReset(t *testing.T) {
|
||||||
|
b := NewLogBuffer(1000)
|
||||||
|
sel := allLevels()
|
||||||
|
b.Append(LogLevelInfo, "old")
|
||||||
|
b.Append(LogLevelInfo, "base")
|
||||||
|
|
||||||
|
text, full := b.RenderNew(sel)
|
||||||
|
if full || text != "old\nbase\n" {
|
||||||
|
t.Fatalf("RenderNew = (%q, %v), want (\"old\\nbase\\n\", false)", text, full)
|
||||||
|
}
|
||||||
|
b.ResetRendered() // имитация полной пересборки ленты
|
||||||
|
|
||||||
|
b.Append(LogLevelInfo, "new")
|
||||||
|
text, full = b.RenderNew(sel)
|
||||||
|
if full || text != "new\n" {
|
||||||
|
t.Fatalf("RenderNew после Reset = (%q, %v), want (\"new\\n\", false)", text, full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вытеснение уже выведенных записей помечает рендер как требующий полного
|
||||||
|
// пересбора (stale): дельта не может убрать верхние строки.
|
||||||
|
func TestLogBufferRenderNewEvictionStale(t *testing.T) {
|
||||||
|
b := NewLogBuffer(25) // записи по 10 байт + \n: помещаются 2, 3-я вытесняет 1-ю
|
||||||
|
sel := allLevels()
|
||||||
|
|
||||||
|
b.Append(LogLevelInfo, "0123456789")
|
||||||
|
b.Append(LogLevelInfo, "0123456789")
|
||||||
|
text, full := b.RenderNew(sel)
|
||||||
|
if full || text != "0123456789\n0123456789\n" {
|
||||||
|
t.Fatalf("RenderNew = (%q, %v), want two lines", text, full)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Третья запись вытесняет первую (уже отрисованную) — stale.
|
||||||
|
b.Append(LogLevelInfo, "0123456789")
|
||||||
|
text, full = b.RenderNew(sel)
|
||||||
|
if !full {
|
||||||
|
t.Fatalf("RenderNew после вытеснения = (full=%v), want full=true", full)
|
||||||
|
}
|
||||||
|
if text != "0123456789\n0123456789\n" {
|
||||||
|
t.Fatalf("полный текст после вытеснения = %q, want последние две записи", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/ui/session.go
Normal file
43
internal/ui/session.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session — одна вкладка-сессия UI: независимый диалог с LLM.
|
||||||
|
//
|
||||||
|
// Каждая вкладка имеет собственный UserID и Address, поэтому core видит её как
|
||||||
|
// отдельный чат со своим активным task (политика «одна задача на чат» работает
|
||||||
|
// из коробки). Свободная сессия (BoundTask==0) — in-memory диалог с моделью
|
||||||
|
// вне машины задач; task-сессия (BoundTask>0) — диалог конкретной задачи, у
|
||||||
|
// которой UserID == task.ChatID (чат задачи уникален).
|
||||||
|
type Session struct {
|
||||||
|
UserID chat.UserID
|
||||||
|
Address chat.Address
|
||||||
|
Title string
|
||||||
|
BoundTask int64 // 0 = свободный чат; >0 — задача, к которой привязана вкладка
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFreeSession создаёт свободную сессию (без задачи). userID/address должны
|
||||||
|
// быть уникальными (например "ui://sess-<n>"). Транскрипт живёт только в памяти.
|
||||||
|
func NewFreeSession(userID chat.UserID, address chat.Address) *Session {
|
||||||
|
return &Session{UserID: userID, Address: address, Title: "Новый чат", BoundTask: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskSession создаёт сессию, привязанную к задаче. UserID == task.ChatID,
|
||||||
|
// чтобы Router/core находили активную задачу этой вкладки. address — уникальный
|
||||||
|
// адрес доставки вкладки (не должен совпадать с UserID других вкладок).
|
||||||
|
func NewTaskSession(address chat.Address, t storage.Task) *Session {
|
||||||
|
title := t.Title
|
||||||
|
if title == "" {
|
||||||
|
title = "Задача #" + Itoa(t.ID)
|
||||||
|
}
|
||||||
|
return &Session{UserID: chat.UserID(t.ChatID), Address: address, Title: title, BoundTask: t.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionID — префикс идентификаторов UI-сессий (и UserID, и Address).
|
||||||
|
const SessionID = "ui://sess-"
|
||||||
|
|
||||||
|
// IsFreeSession — true, если вкладка не привязана к задаче.
|
||||||
|
func IsFreeSession(s *Session) bool { return s != nil && s.BoundTask == 0 }
|
||||||
55
internal/ui/session_test.go
Normal file
55
internal/ui/session_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewFreeSession(t *testing.T) {
|
||||||
|
s := NewFreeSession(chat.UserID("ui://sess-1"), chat.Address("ui://sess-1"))
|
||||||
|
if s.UserID != chat.UserID("ui://sess-1") {
|
||||||
|
t.Errorf("UserID = %q, want ui://sess-1", s.UserID)
|
||||||
|
}
|
||||||
|
if s.Address != chat.Address("ui://sess-1") {
|
||||||
|
t.Errorf("Address = %q, want ui://sess-1", s.Address)
|
||||||
|
}
|
||||||
|
if s.BoundTask != 0 {
|
||||||
|
t.Errorf("BoundTask = %d, want 0", s.BoundTask)
|
||||||
|
}
|
||||||
|
if !IsFreeSession(s) {
|
||||||
|
t.Error("free session must be free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTaskSessionUsesTaskChatID(t *testing.T) {
|
||||||
|
task := &storage.Task{ID: 7, ChatID: "ui://sess-3", Title: "Фича"}
|
||||||
|
s := NewTaskSession(chat.Address("ui://sess-3"), *task)
|
||||||
|
if s.UserID != chat.UserID("ui://sess-3") {
|
||||||
|
t.Errorf("UserID = %q, want task.ChatID", s.UserID)
|
||||||
|
}
|
||||||
|
if s.BoundTask != 7 {
|
||||||
|
t.Errorf("BoundTask = %d, want 7", s.BoundTask)
|
||||||
|
}
|
||||||
|
if s.Title != "Фича" {
|
||||||
|
t.Errorf("Title = %q, want Фича", s.Title)
|
||||||
|
}
|
||||||
|
if IsFreeSession(s) {
|
||||||
|
t.Error("task session must not be free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTaskSessionTitleFallback(t *testing.T) {
|
||||||
|
task := &storage.Task{ID: 3, ChatID: "ui://sess-3"}
|
||||||
|
s := NewTaskSession(chat.Address("ui://sess-3"), *task)
|
||||||
|
if s.Title != "Задача #3" {
|
||||||
|
t.Errorf("Title = %q, want Задача #3", s.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsFreeSessionNil(t *testing.T) {
|
||||||
|
if IsFreeSession(nil) {
|
||||||
|
t.Error("nil is not free")
|
||||||
|
}
|
||||||
|
}
|
||||||
99
internal/ui/state_panel.go
Normal file
99
internal/ui/state_panel.go
Normal 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:])
|
||||||
|
}
|
||||||
50
internal/ui/state_panel_test.go
Normal file
50
internal/ui/state_panel_test.go
Normal 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
73
internal/ui/store.go
Normal 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
107
internal/ui/store_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/ui/task_detail.go
Normal file
37
internal/ui/task_detail.go
Normal 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
|
||||||
|
}
|
||||||
36
internal/ui/task_detail_test.go
Normal file
36
internal/ui/task_detail_test.go
Normal 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()
|
||||||
|
}
|
||||||
40
internal/ui/task_list_panel.go
Normal file
40
internal/ui/task_list_panel.go
Normal 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)
|
||||||
|
}
|
||||||
40
internal/ui/task_list_panel_test.go
Normal file
40
internal/ui/task_list_panel_test.go
Normal 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
40
internal/ui/view.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Package ui — контроллер и представления десктопного интерфейса (Fyne).
|
||||||
|
//
|
||||||
|
// Слой обмена с ядром: однонаправленный поток (спец 12.2–12.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
68
internal/ui/window.go
Normal 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) {}
|
||||||
80
internal/ui/window_test.go
Normal file
80
internal/ui/window_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,14 @@ type OpenCodeRunner interface {
|
|||||||
// PollTaskFunc — callback для обработки готовой задачи (подменяемый в тестах).
|
// PollTaskFunc — callback для обработки готовой задачи (подменяемый в тестах).
|
||||||
type PollTaskFunc func(ctx context.Context) error
|
type PollTaskFunc func(ctx context.Context) error
|
||||||
|
|
||||||
|
// Notifier — механизм отправки авто-уведомлений владельцу задачи во время
|
||||||
|
// выполнения. В проде реализуется *app.App через chat.Router.Send (см.
|
||||||
|
// internal/app/app.go → App.Notify); в тестах worker подменяется фейковым
|
||||||
|
// нотифаером. nil — уведомления выключены (ничего не отправляется).
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, taskID int64, chatID, text string) error
|
||||||
|
}
|
||||||
|
|
||||||
// Worker — планировщик, запускающий готовые задачи (status=ready → running → success/failed/timeout).
|
// Worker — планировщик, запускающий готовые задачи (status=ready → running → success/failed/timeout).
|
||||||
type Worker struct {
|
type Worker struct {
|
||||||
Store *storage.Storage
|
Store *storage.Storage
|
||||||
@@ -39,11 +48,36 @@ type Worker struct {
|
|||||||
// Через него Runner пишет live-шаги задачи; nil — наблюдение выключено.
|
// Через него Runner пишет live-шаги задачи; nil — наблюдение выключено.
|
||||||
Live *opencode.LiveRegistry
|
Live *opencode.LiveRegistry
|
||||||
|
|
||||||
|
// Notify — нотифаер авто-уведомлений владельцу задачи (статусы + хендоффы
|
||||||
|
// dev↔reviewer). nil — уведомления выключены.
|
||||||
|
Notify Notifier
|
||||||
|
|
||||||
sem chan struct{} // семафор
|
sem chan struct{} // семафор
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
||||||
// подменяемый poll для тестов
|
// подменяемый poll для тестов
|
||||||
pollFn PollTaskFunc
|
pollFn PollTaskFunc
|
||||||
|
|
||||||
|
// Events — издатель доменных событий для UI. nil — события выключены.
|
||||||
|
Events events.Publisher
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish отправляет доменное событие, если задан издатель.
|
||||||
|
func (w *Worker) publish(e events.Event) {
|
||||||
|
if w.Events != nil {
|
||||||
|
w.Events.Publish(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStatus переводит задачу в новый статус: сохраняет в БД и публикует событие.
|
||||||
|
func (w *Worker) setStatus(ctx context.Context, task *storage.Task, to storage.Status) error {
|
||||||
|
from := task.Status
|
||||||
|
task.Status = to
|
||||||
|
if err := w.Store.UpdateTask(ctx, task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: to})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCtx оборачивает контекст запуска субагента, привязывая живое наблюдение
|
// runCtx оборачивает контекст запуска субагента, привязывая живое наблюдение
|
||||||
@@ -55,6 +89,27 @@ func (w *Worker) runCtx(ctx context.Context, taskID int64) context.Context {
|
|||||||
return opencode.WithLive(ctx, w.Live, taskID)
|
return opencode.WithLive(ctx, w.Live, taskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notify отправляет авто-уведомление владельцу задачи, если нотифаер задан.
|
||||||
|
func (w *Worker) notify(ctx context.Context, task *storage.Task, text string) {
|
||||||
|
if w.Notify == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := w.Notify.Notify(ctx, task.ID, task.ChatID, text); err != nil {
|
||||||
|
log.Printf("worker: task %d: уведомление: %v", task.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyStatus — уведомление о смене статуса задачи (номер задачи + статус).
|
||||||
|
func (w *Worker) notifyStatus(ctx context.Context, task *storage.Task, s storage.Status) {
|
||||||
|
w.notify(ctx, task, fmt.Sprintf("Задача #%d: %s", task.ID, s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyHandoff — уведомление о передаче задачи между агентами конвейера
|
||||||
|
// на заданной итерации (1-based).
|
||||||
|
func (w *Worker) notifyHandoff(ctx context.Context, task *storage.Task, from, to string, iteration int) {
|
||||||
|
w.notify(ctx, task, fmt.Sprintf("Задача #%d: %s → %s (итерация %d)", task.ID, from, to, iteration))
|
||||||
|
}
|
||||||
|
|
||||||
// Start запускает цикл опроса в фоновой горутине.
|
// Start запускает цикл опроса в фоновой горутине.
|
||||||
func (w *Worker) Start(ctx context.Context) {
|
func (w *Worker) Start(ctx context.Context) {
|
||||||
if w.Agent == "" {
|
if w.Agent == "" {
|
||||||
@@ -113,7 +168,7 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks, err := w.Store.ListTasks(ctx, storage.TaskFilter{
|
tasks, err := w.Store.ListTasks(ctx, storage.TaskFilter{
|
||||||
Status: storage.StatusReady,
|
Status: storage.StatusApproved,
|
||||||
Limit: slots,
|
Limit: slots,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,7 +197,7 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error {
|
|||||||
// dev дорабатывает по комментариям; прошло → push ветки + success.
|
// dev дорабатывает по комментариям; прошло → push ветки + success.
|
||||||
func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||||
// 1. проверяем статус
|
// 1. проверяем статус
|
||||||
if task.Status != storage.StatusReady {
|
if task.Status != storage.StatusApproved {
|
||||||
return fmt.Errorf("%w: task %d status=%q", ErrLaunch, task.ID, task.Status)
|
return fmt.Errorf("%w: task %d status=%q", ErrLaunch, task.ID, task.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,10 +214,10 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. ставим running (после валидации — чтобы плохие имена не жгли состояние)
|
// 2. ставим running (после валидации — чтобы плохие имена не жгли состояние)
|
||||||
task.Status = storage.StatusRunning
|
if err := w.setStatus(ctx, task, storage.StatusRunning); err != nil {
|
||||||
if err := w.Store.UpdateTask(ctx, task); err != nil {
|
|
||||||
return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
|
return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusRunning)
|
||||||
|
|
||||||
// 2b. клонируем недостающие репозитории в общий каталог.
|
// 2b. клонируем недостающие репозитории в общий каталог.
|
||||||
if err := w.prepareRepos(ctx, repos); err != nil {
|
if err := w.prepareRepos(ctx, repos); err != nil {
|
||||||
@@ -227,17 +282,17 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
|||||||
case 0:
|
case 0:
|
||||||
// продолжаем на ревью
|
// продолжаем на ревью
|
||||||
case -1:
|
case -1:
|
||||||
task.Status = storage.StatusTimeout
|
if e := w.setStatus(ctx, task, storage.StatusTimeout); e != nil {
|
||||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusTimeout)
|
||||||
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
|
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
task.Status = storage.StatusFailed
|
if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
|
||||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusFailed)
|
||||||
w.finalizeTrace(ctx, traceID, storage.TraceFailed, output)
|
w.finalizeTrace(ctx, traceID, storage.TraceFailed, output)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -245,6 +300,9 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
|||||||
// dev завершился RC=0 → сохраняем успех трассы dev.
|
// dev завершился RC=0 → сохраняем успех трассы dev.
|
||||||
w.finalizeTrace(ctx, traceID, storage.TraceSuccess, output)
|
w.finalizeTrace(ctx, traceID, storage.TraceSuccess, output)
|
||||||
|
|
||||||
|
// уведомляем пользователя о передаче dev → reviewer на ревью.
|
||||||
|
w.notifyHandoff(ctx, task, "dev", "reviewer", iter+1)
|
||||||
|
|
||||||
// 8. РЕВЬЮ: собираем diff всей ветки, запускаем reviewer.
|
// 8. РЕВЬЮ: собираем diff всей ветки, запускаем reviewer.
|
||||||
diffText, dErr := w.branchDiffAll(ctx, repos, branch)
|
diffText, dErr := w.branchDiffAll(ctx, repos, branch)
|
||||||
if dErr != nil {
|
if dErr != nil {
|
||||||
@@ -270,11 +328,11 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
|||||||
|
|
||||||
if verdict == nil {
|
if verdict == nil {
|
||||||
// невалидный JSON даже после retry → failed с объяснением.
|
// невалидный JSON даже после retry → failed с объяснением.
|
||||||
task.Status = storage.StatusFailed
|
|
||||||
explain := "reviewer вернул невалидный/пустой вердикт (даже после повтора)."
|
explain := "reviewer вернул невалидный/пустой вердикт (даже после повтора)."
|
||||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusFailed)
|
||||||
w.finalizeTrace(ctx, reviewTraceID, storage.TraceFailed, reviewOutput+"\n"+explain)
|
w.finalizeTrace(ctx, reviewTraceID, storage.TraceFailed, reviewOutput+"\n"+explain)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -285,24 +343,25 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
|||||||
w.failTask(ctx, task)
|
w.failTask(ctx, task)
|
||||||
return pErr
|
return pErr
|
||||||
}
|
}
|
||||||
task.Status = storage.StatusSuccess
|
if e := w.setStatus(ctx, task, storage.StatusSuccess); e != nil {
|
||||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusSuccess)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Не пройдено: если есть итерации — dev дорабатывает.
|
// Не пройдено: если есть итерации — dev дорабатывает.
|
||||||
if iter+1 < maxReviewIterations {
|
if iter+1 < maxReviewIterations {
|
||||||
|
w.notify(ctx, task, fmt.Sprintf("Задача #%d: reviewer → dev на доработку (итерация %d)", task.ID, iter+1))
|
||||||
feedback = verdict.Comments
|
feedback = verdict.Comments
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Лимит исчерпан → failed с объяснением.
|
// Лимит исчерпан → failed с объяснением.
|
||||||
task.Status = storage.StatusFailed
|
if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
|
||||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||||
}
|
}
|
||||||
|
w.notify(ctx, task, fmt.Sprintf("Задача #%d: failed — ревью не пройдено за %d итераций", task.ID, maxReviewIterations))
|
||||||
explain := fmt.Sprintf("Ревью не пройдено за %d итераций.", maxReviewIterations)
|
explain := fmt.Sprintf("Ревью не пройдено за %d итераций.", maxReviewIterations)
|
||||||
final := reviewOutput + "\n" + explain
|
final := reviewOutput + "\n" + explain
|
||||||
if e := w.Store.UpdateTraceOutput(ctx, reviewTraceID, final); e != nil {
|
if e := w.Store.UpdateTraceOutput(ctx, reviewTraceID, final); e != nil {
|
||||||
@@ -330,12 +389,12 @@ func (w *Worker) reviewWithRetry(ctx context.Context, taskID int64, cwd, prompt
|
|||||||
return v2, out2, tid2, nil
|
return v2, out2, tid2, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// failTask помечает задачу failed.
|
// failTask помечает задачу failed и уведомляет владельца.
|
||||||
func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
|
func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
|
||||||
task.Status = storage.StatusFailed
|
if e := w.setStatus(ctx, task, storage.StatusFailed); e != nil {
|
||||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
|
||||||
log.Printf("worker: task %d: set failed: %v", task.ID, e)
|
log.Printf("worker: task %d: set failed: %v", task.ID, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalizeTrace обновляет output и статус трассы.
|
// finalizeTrace обновляет output и статус трассы.
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -17,6 +17,32 @@ import (
|
|||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// fakeNotifier — фейковый нотифаер, собирающий все авто-уведомления воркера.
|
||||||
|
type fakeNotifier struct {
|
||||||
|
notifs []notifCall
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifCall — одно перехваченное уведомление.
|
||||||
|
type notifCall struct {
|
||||||
|
taskID int64
|
||||||
|
chatID string
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNotifier) Notify(_ context.Context, taskID int64, chatID, text string) error {
|
||||||
|
f.notifs = append(f.notifs, notifCall{taskID: taskID, chatID: chatID, text: text})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifTexts возвращает тексты уведомлений в порядке отправки.
|
||||||
|
func notifTexts(n *fakeNotifier) []string {
|
||||||
|
texts := make([]string, len(n.notifs))
|
||||||
|
for i, c := range n.notifs {
|
||||||
|
texts[i] = c.text
|
||||||
|
}
|
||||||
|
return texts
|
||||||
|
}
|
||||||
|
|
||||||
type mockRunnerWorker struct {
|
type mockRunnerWorker struct {
|
||||||
result *opencode.Result
|
result *opencode.Result
|
||||||
err error
|
err error
|
||||||
@@ -95,6 +121,10 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta
|
|||||||
if err := s.UpdateTask(ctx, task); err != nil {
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
t.Fatalf("set ready: %v", err)
|
t.Fatalf("set ready: %v", err)
|
||||||
}
|
}
|
||||||
|
task.Status = storage.StatusApproved
|
||||||
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("set approved: %v", err)
|
||||||
|
}
|
||||||
task, _ = s.GetTask(ctx, id)
|
task, _ = s.GetTask(ctx, id)
|
||||||
return task
|
return task
|
||||||
}
|
}
|
||||||
@@ -259,6 +289,186 @@ func TestWorkerReviewMaxIterations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWorkerStatusNotifications — happy path: владелец получает уведомления
|
||||||
|
// на каждый переход статуса со стороны воркера (running → success)
|
||||||
|
// и на хендофф dev→reviewer.
|
||||||
|
func TestWorkerStatusNotifications(t *testing.T) {
|
||||||
|
s := setupWorkerDB(t)
|
||||||
|
task := createReadyTask(t, s, "notif-ok")
|
||||||
|
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
w := &Worker{
|
||||||
|
Store: s,
|
||||||
|
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"}},
|
||||||
|
Worktree: t.TempDir(),
|
||||||
|
Agent: "dev",
|
||||||
|
Notify: n,
|
||||||
|
}
|
||||||
|
seedFakeRepo(t, w.Worktree, "notif-ok")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := w.runTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("runTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(n.notifs) != 3 {
|
||||||
|
t.Fatalf("уведомлений = %d, want 3 (running, dev→reviewer, success)", len(n.notifs))
|
||||||
|
}
|
||||||
|
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||||
|
want := []string{
|
||||||
|
prefix + ": running",
|
||||||
|
prefix + ": dev → reviewer (итерация 1)",
|
||||||
|
prefix + ": success",
|
||||||
|
}
|
||||||
|
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
// все уведомления уходят владельцу задачи (task.ChatID)
|
||||||
|
for _, c := range n.notifs {
|
||||||
|
if c.chatID != task.ChatID {
|
||||||
|
t.Errorf("уведомление ушло в %q, want %q", c.chatID, task.ChatID)
|
||||||
|
}
|
||||||
|
if c.taskID != task.ID {
|
||||||
|
t.Errorf("уведомление для задачи %d, want %d", c.taskID, task.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkerHandoffNotifications — цикл dev↔review: уведомления на оба хендоффа
|
||||||
|
// (dev→reviewer и reviewer→dev на доработку) с номером задачи и итерации.
|
||||||
|
func TestWorkerHandoffNotifications(t *testing.T) {
|
||||||
|
s := setupWorkerDB(t)
|
||||||
|
task := createReadyTask(t, s, "notif-loop")
|
||||||
|
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
w := &Worker{
|
||||||
|
Store: s,
|
||||||
|
Runner: &mockRunnerWorker{
|
||||||
|
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
|
||||||
|
reviewSequence: []*opencode.Result{
|
||||||
|
reviewFailedRunner(),
|
||||||
|
{RC: 0, Stdout: `{"passed":true,"comments":[]}`},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Worktree: t.TempDir(),
|
||||||
|
Agent: "dev",
|
||||||
|
Notify: n,
|
||||||
|
}
|
||||||
|
seedFakeRepo(t, w.Worktree, "notif-loop")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := w.runTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("runTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||||
|
want := []string{
|
||||||
|
prefix + ": running",
|
||||||
|
prefix + ": dev → reviewer (итерация 1)",
|
||||||
|
prefix + ": reviewer → dev на доработку (итерация 1)",
|
||||||
|
prefix + ": dev → reviewer (итерация 2)",
|
||||||
|
prefix + ": success",
|
||||||
|
}
|
||||||
|
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkerIterationsLimitNotification — при исчерпании лимита итераций
|
||||||
|
// владельцу уходит одно уведомление о failed (без дублей с running→failed).
|
||||||
|
func TestWorkerIterationsLimitNotification(t *testing.T) {
|
||||||
|
s := setupWorkerDB(t)
|
||||||
|
task := createReadyTask(t, s, "notif-lim")
|
||||||
|
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
w := &Worker{
|
||||||
|
Store: s,
|
||||||
|
Runner: &mockRunnerWorker{
|
||||||
|
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
|
||||||
|
reviewResult: reviewFailedRunner(),
|
||||||
|
},
|
||||||
|
Worktree: t.TempDir(),
|
||||||
|
Agent: "dev",
|
||||||
|
Notify: n,
|
||||||
|
}
|
||||||
|
seedFakeRepo(t, w.Worktree, "notif-lim")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := w.runTask(ctx, task); err != nil {
|
||||||
|
t.Fatalf("runTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
texts := notifTexts(n)
|
||||||
|
if len(texts) == 0 {
|
||||||
|
t.Fatal("нет уведомлений")
|
||||||
|
}
|
||||||
|
last := texts[len(texts)-1]
|
||||||
|
if !strings.Contains(last, "failed") {
|
||||||
|
t.Errorf("последнее уведомление = %q, want упоминание failed", last)
|
||||||
|
}
|
||||||
|
if !strings.Contains(last, "итераци") {
|
||||||
|
t.Errorf("последнее уведомление = %q, want упоминание лимита итераций", last)
|
||||||
|
}
|
||||||
|
// ровно одно уведомление о failed (running→failed не задваивается)
|
||||||
|
var failedCount int
|
||||||
|
for _, txt := range texts {
|
||||||
|
if strings.Contains(txt, ": failed") {
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failedCount != 1 {
|
||||||
|
t.Errorf("уведомлений о failed = %d, want ровно 1: %#v", failedCount, texts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkerTimeoutNotification — RC=-1 (таймаут dev) → уведомление о timeout.
|
||||||
|
func TestWorkerTimeoutNotification(t *testing.T) {
|
||||||
|
s := setupWorkerDB(t)
|
||||||
|
task := createReadyTask(t, s, "notif-timeout")
|
||||||
|
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
w := &Worker{
|
||||||
|
Store: s,
|
||||||
|
Runner: &mockRunnerWorker{result: &opencode.Result{RC: -1, Stdout: ""}},
|
||||||
|
Worktree: t.TempDir(),
|
||||||
|
Notify: n,
|
||||||
|
}
|
||||||
|
seedFakeRepo(t, w.Worktree, "notif-timeout")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = w.runTask(ctx, task)
|
||||||
|
|
||||||
|
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||||
|
want := []string{prefix + ": running", prefix + ": timeout"}
|
||||||
|
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkerSpawnErrorNotification — сбой запуска dev → уведомление о failed.
|
||||||
|
func TestWorkerSpawnErrorNotification(t *testing.T) {
|
||||||
|
s := setupWorkerDB(t)
|
||||||
|
task := createReadyTask(t, s, "notif-spawn")
|
||||||
|
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
w := &Worker{
|
||||||
|
Store: s,
|
||||||
|
Runner: &mockRunnerWorker{err: errors.New("opencode not found")},
|
||||||
|
Worktree: t.TempDir(),
|
||||||
|
Notify: n,
|
||||||
|
}
|
||||||
|
seedFakeRepo(t, w.Worktree, "notif-spawn")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = w.runTask(ctx, task)
|
||||||
|
|
||||||
|
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||||
|
want := []string{prefix + ": running", prefix + ": failed"}
|
||||||
|
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// reviewNDJSONRunner возвращает вердикт ревьюера как реальный NDJSON-поток opencode,
|
// reviewNDJSONRunner возвращает вердикт ревьюера как реальный NDJSON-поток opencode,
|
||||||
// где JSON находится внутри последнего text-парта.
|
// где JSON находится внутри последнего text-парта.
|
||||||
func reviewNDJSONRunner(v *reviewVerdict) *opencode.Result {
|
func reviewNDJSONRunner(v *reviewVerdict) *opencode.Result {
|
||||||
@@ -598,13 +808,41 @@ func TestWorkerStartStop(t *testing.T) {
|
|||||||
w.Stop()
|
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) {
|
func TestWorkerSemaphore(t *testing.T) {
|
||||||
s := setupWorkerDB(t)
|
s := setupWorkerDB(t)
|
||||||
|
|
||||||
// создаём 2 ready-задачи
|
// создаём 2 ready-задачи
|
||||||
for i := 0; i < 2; i++ {
|
task1 := createReadyTask(t, s, "task-0")
|
||||||
createReadyTask(t, s, fmt.Sprintf("task-%d", i))
|
task2 := createReadyTask(t, s, "task-1")
|
||||||
}
|
|
||||||
|
|
||||||
w := &Worker{
|
w := &Worker{
|
||||||
Store: s,
|
Store: s,
|
||||||
@@ -618,26 +856,26 @@ func TestWorkerSemaphore(t *testing.T) {
|
|||||||
w.sem = make(chan struct{}, 1)
|
w.sem = make(chan struct{}, 1)
|
||||||
w.sem <- struct{}{}
|
w.sem <- struct{}{}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// первый poll — запустит 1 задачу (макс. 1)
|
// первый poll — запустит 1 задачу (макс. 1)
|
||||||
w.pollAndDispatch(ctx)
|
w.pollAndDispatch(ctx)
|
||||||
time.Sleep(200 * time.Millisecond)
|
waitTaskStatus(t, ctx, s, task1.ID, storage.StatusSuccess)
|
||||||
|
|
||||||
// 1 должна быть success, 1 — всё ещё ready
|
// 1 должна быть success, 1 — всё ещё approved
|
||||||
success, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
|
success, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
|
||||||
ready, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusReady})
|
approved, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusApproved})
|
||||||
if len(success) != 1 {
|
if len(success) != 1 {
|
||||||
t.Errorf("success = %d, want 1 (ready=%d)", len(success), len(ready))
|
t.Errorf("success = %d, want 1 (approved=%d)", len(success), len(approved))
|
||||||
}
|
}
|
||||||
if len(ready) != 1 {
|
if len(approved) != 1 {
|
||||||
t.Errorf("ready = %d, want 1", len(ready))
|
t.Errorf("approved = %d, want 1", len(approved))
|
||||||
}
|
}
|
||||||
|
|
||||||
// первая завершилась и вернула токен в сем — можем диспатчить вторую
|
// первая завершилась и вернула токен в сем — можем диспатчить вторую
|
||||||
w.pollAndDispatch(ctx)
|
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})
|
success, _ = s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
|
||||||
if len(success) != 2 {
|
if len(success) != 2 {
|
||||||
|
|||||||
145
scripts/build-publish-ui.ps1
Normal file
145
scripts/build-publish-ui.ps1
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<#
|
||||||
|
build-publish-ui.ps1 — локальная сборка Windows-бинаря с UI (Fyne) и публикация в Gitea Packages.
|
||||||
|
|
||||||
|
Почему локально: Fyne-окно требует cgo + C-компилятор (MinGW/gcc), которых нет в CI.
|
||||||
|
CI собирает ТОЛЬКО headless-linux (см. .gitea/workflows/ci.yaml). Windows-бинарь
|
||||||
|
с графическим окном собирается на Windows-машине Камиля и публикуется скриптом ниже
|
||||||
|
в ту же версию commit-<sha7>, что и CI, с теми же companion-файлами (.version/.sha256),
|
||||||
|
чтобы автообновление (U4 checksum, ResolveLatest) работало без изменений.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
- Go с доступным C-компилятором (CGO_ENABLED=1). WinLibs gcc: см. память
|
||||||
|
`mem:binator/cgo-winlibs-gcc`. Каталог с gcc.exe можно задать через
|
||||||
|
GCC_PATH (ниже); если не задан — gcc должен быть уже в PATH.
|
||||||
|
- Файл scripts/.gitea-creds (git-ignored, см. ниже) с токенами.
|
||||||
|
Формат (по одной `ключ=значение` на строку):
|
||||||
|
GITEA_TOKEN=write:packages-токен
|
||||||
|
UPDATE_TOKEN=read:package-токен
|
||||||
|
GIT_MAIN_URL=http://gitea.hal9000.home
|
||||||
|
PROJECT_PATH=..\..\foo (опционально) корень проекта (где лежат cmd/
|
||||||
|
и go.mod); относительно папки scripts/ или
|
||||||
|
абсолютный; без него — папка рядом с
|
||||||
|
скриптом
|
||||||
|
GCC_PATH=D:\MinGW\bin (опционально) каталог, где лежит gcc.exe;
|
||||||
|
дописывается в PATH перед проверкой и
|
||||||
|
сборкой; абсолютный или относительно
|
||||||
|
scripts/; без него — gcc из текущего PATH
|
||||||
|
- Ветка уже запушена в `main` (VERSION = commit-<sha7> от текущего HEAD).
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts/build-publish-ui.ps1
|
||||||
|
#>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$CredsFile = Join-Path $ScriptDir ".gitea-creds"
|
||||||
|
$Package = "ratatoskr"
|
||||||
|
$Owner = "kamelion"
|
||||||
|
$Filename = "ratatoskr-windows-amd64.exe"
|
||||||
|
|
||||||
|
# --- 0. Проверка флага CGO_ENABLED (не зависит от creds) ---
|
||||||
|
if ($env:CGO_ENABLED -eq "0") { Write-Error "CGO_ENABLED=0 — нужен C-компилятор (MinGW). Уберите его из env." }
|
||||||
|
|
||||||
|
# --- 1. Чтение секретов ---
|
||||||
|
if (-not (Test-Path $CredsFile)) {
|
||||||
|
Write-Error "Нет файла секретов: $CredsFile`nФормат: GITEA_TOKEN=... / UPDATE_TOKEN=... / GIT_MAIN_URL=..."
|
||||||
|
}
|
||||||
|
$creds = @{}
|
||||||
|
Get-Content $CredsFile | ForEach-Object {
|
||||||
|
$line = $_.Trim()
|
||||||
|
if ($line -and -not $line.StartsWith("#")) {
|
||||||
|
$kv = $line.Split("=", 2)
|
||||||
|
if ($kv.Count -eq 2) { $creds[$kv[0].Trim()] = $kv[1].Trim() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$giteaToken = $creds["GITEA_TOKEN"]
|
||||||
|
$updateToken = $creds["UPDATE_TOKEN"]
|
||||||
|
$giteaUrl = $creds["GIT_MAIN_URL"]
|
||||||
|
if (-not $giteaToken) { Write-Error "В $CredsFile нет GITEA_TOKEN (нужен write:packages)." }
|
||||||
|
if (-not $giteaUrl) { Write-Error "В $CredsFile нет GIT_MAIN_URL." }
|
||||||
|
|
||||||
|
# --- 1a. C-тулчейн (gcc): GCC_PATH из creds дописывается в PATH ---
|
||||||
|
# Путь абсолютный либо относительно папки scripts/; без GCC_PATH — gcc
|
||||||
|
# берётся из уже установленного PATH.
|
||||||
|
$gccPath = $creds["GCC_PATH"]
|
||||||
|
if ($gccPath -ne "" -and $gccPath) {
|
||||||
|
$gccAbs = Join-Path $ScriptDir $gccPath
|
||||||
|
$gccBin = (Resolve-Path -LiteralPath $gccAbs -ErrorAction SilentlyContinue).Path
|
||||||
|
if (-not $gccBin) {
|
||||||
|
$gccAbs = $gccPath # не нашлось относительно scripts/ — пробуем как абсолютный
|
||||||
|
$gccBin = (Resolve-Path -LiteralPath $gccAbs -ErrorAction SilentlyContinue).Path
|
||||||
|
}
|
||||||
|
if (-not $gccBin) { Write-Error "GCC_PATH не найден: $gccPath (или относительно $ScriptDir)" }
|
||||||
|
$env:Path = "$gccBin;$env:Path"
|
||||||
|
Write-Host "[gcc] PATH += $gccBin"
|
||||||
|
}
|
||||||
|
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "gcc не найден в PATH. Пример (WinLibs):"
|
||||||
|
Write-Host ' D:\MinGW\bin, затем:
|
||||||
|
GCC_PATH=D:\MinGW\bin — в scripts/.gitea-creds'
|
||||||
|
Write-Error "C-компилятор gcc не найден."
|
||||||
|
}
|
||||||
|
Write-Host "[ok] gcc: $((gcc --version | Select-Object -First 1))"
|
||||||
|
|
||||||
|
# --- 1b. Корень целевого проекта ---
|
||||||
|
# PROJECT_PATH из .gitea-creds (относительно папки scripts/) либо, если не задан,
|
||||||
|
# папка рядом со скриптом (прежнее поведение).
|
||||||
|
$projPath = $creds["PROJECT_PATH"]
|
||||||
|
if ($projPath -ne "" -and $projPath) {
|
||||||
|
$RepoRoot = (Resolve-Path -LiteralPath (Join-Path $ScriptDir $projPath)).Path
|
||||||
|
if (-not $RepoRoot) { Write-Error "PROJECT_PATH не найден: $projPath (относительно $ScriptDir)" }
|
||||||
|
} else {
|
||||||
|
$RepoRoot = Split-Path -Parent $ScriptDir
|
||||||
|
}
|
||||||
|
Set-Location $RepoRoot
|
||||||
|
Write-Host "[root] $RepoRoot"
|
||||||
|
|
||||||
|
# --- 2. Версия = commit-<sha7> текущего HEAD (совпадает с именованием CI) ---
|
||||||
|
$sha = (git rev-parse --short HEAD).Trim()
|
||||||
|
if (-not $sha) { Write-Error "git rev-parse --short HEAD не дал хэш." }
|
||||||
|
$Version = "commit-$sha"
|
||||||
|
Write-Host "[build] VERSION=$Version FILENAME=$Filename"
|
||||||
|
|
||||||
|
# --- 3. Сборка (cgo + UI) ---
|
||||||
|
$env:CGO_ENABLED = "1"
|
||||||
|
$out = Join-Path $RepoRoot $Filename
|
||||||
|
$ldflags = "-s -w -X main.version=${Version} -X main.updateToken=${updateToken}"
|
||||||
|
Write-Host "[build] go build -ldflags=... -o $Filename"
|
||||||
|
$goArgs = @("build", "-ldflags=$ldflags", "-o", $out, "./cmd/ratatoskr/")
|
||||||
|
go $goArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "go build завершился с кодом $LASTEXITCODE" }
|
||||||
|
|
||||||
|
# --- 4. Companion-файлы (.version, .sha256) ---
|
||||||
|
$sha256Hex = (Get-FileHash -Algorithm SHA256 $out).Hash.ToLower()
|
||||||
|
$verFile = "$out.version"
|
||||||
|
$sumFile = "$out.sha256"
|
||||||
|
Set-Content -Path $verFile -Value $Version -NoNewline -Encoding ascii
|
||||||
|
Set-Content -Path $sumFile -Value $sha256Hex -NoNewline -Encoding ascii
|
||||||
|
Write-Host "[ok] bin: $out ($([math]::Round((Get-Item $out).Length/1MB,1)) MB)"
|
||||||
|
Write-Host "[ok] sha256: $sha256Hex"
|
||||||
|
|
||||||
|
# --- 5. Публикация в Gitea Packages ---
|
||||||
|
$api = "$giteaUrl/api/packages/$Owner/generic/$Package/$Version"
|
||||||
|
$files = @($Filename, "$Filename.version", "$Filename.sha256")
|
||||||
|
foreach ($f in $files) {
|
||||||
|
Write-Host "[put] $api/$f"
|
||||||
|
curl.exe -sS -X PUT `
|
||||||
|
-H "Authorization: token $giteaToken" `
|
||||||
|
-H "Content-Type: application/octet-stream" `
|
||||||
|
"$api/$f" `
|
||||||
|
--data-binary "@$f"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "curl PUT $f завершился с кодом $LASTEXITCODE" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 6. Самопроверка: читаем .sha256 обратно и сверяем (контракт U4) ---
|
||||||
|
Write-Host "[verify] перечитываю $Filename.sha256 из Gitea..."
|
||||||
|
$remoteSum = (curl.exe -sS -H "Authorization: token $giteaToken" "$api/$Filename.sha256").Trim()
|
||||||
|
if ($remoteSum -ne $sha256Hex) {
|
||||||
|
Write-Error "Проверка не сошлась: remote=$remoteSum local=$sha256Hex"
|
||||||
|
}
|
||||||
|
$remoteVer = (curl.exe -sS -H "Authorization: token $giteaToken" "$api/$Filename.version").Trim()
|
||||||
|
if ($remoteVer -ne $Version) {
|
||||||
|
Write-Error "Версия не сошлась: remote=$remoteVer local=$Version"
|
||||||
|
}
|
||||||
|
Write-Host "[ok] опубликовано и проверено: $Version/$Filename (+.version, +.sha256)"
|
||||||
Reference in New Issue
Block a user