CI (.gitea/workflows/ci.yaml): - go 1.23 -> 1.25 (modernc.org/sqlite v1.56.0 требует go 1.25; старый CI падал на сборке ещё до тестов). - CGO_ENABLED=0 на уровне workflow: CI собирает/тестирует только headless (ui_noui), не тянет Fyne/glfw и C-компилятор. - timeout 120s -> 300s (worker один ~30-60s на быстрой машине). - build-and-package: только linux/amd64 (headless). Windows-бинарь с UI — локально. Локальная сборка UI + публикация: - scripts/build-publish-ui.ps1: собирает ratatoskr-windows-amd64.exe (CGO_ENABLED=1, WinLibs gcc), вшивает commit-<sha7> + updateToken, пишет .version/.sha256 и публикует в Gitea Packages в ту же версию, что и CI; самопроверка (U4). - scripts/.gitea-creds.example + .gitignore (scripts/.gitea-creds) — секреты не попадают в репозиторий. README: CI=headless + публикация Windows-UI локальным скриптом; обновлён intro (бинарь по умолчанию headless, окно — опционально с cgo).
288 lines
18 KiB
Markdown
288 lines
18 KiB
Markdown
# Ratatoskr-go
|
||
|
||
Ratatoskr на Go — **оркестратор в единый статический бинарь** + субагенты через
|
||
[opencode](https://opencode.ai) как внешний процесс. Переезд с Python-версии:
|
||
все подсистемы (диалог, воркер, автообновление) собраны в один исполняемый
|
||
файл, что упрощает доставку и деплой. Бинарь по умолчанию headless
|
||
(`CGO_ENABLED=0`); графическое окно (Fyne) — опционально, собирается с cgo
|
||
(только локально, см. «Локальная сборка UI-бинаря»).
|
||
|
||
## Структура
|
||
|
||
```
|
||
cmd/ratatoskr/ # точка входа, сборка бинаря (App.New + Run)
|
||
internal/
|
||
app/ # компоновка (composition root), config, DI, packageOwner
|
||
config/ # загрузка YAML+env: ε подстановка ${VAR:-default}, defaults, validate (классы C1–C4)
|
||
chat/ # мультиканальный Router; telegram — long-poll канал
|
||
core/ # state-machine задач: /start /cancel /skip /retry N /status N /continue N
|
||
analyst/ # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1–A4)
|
||
worker/ # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*)
|
||
agents/ # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed
|
||
opencode/ # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1–O5)
|
||
storage/ # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1–S5)
|
||
update/ # автообновление из Gitea Packages (классы U1–U6)
|
||
```
|
||
|
||
## Сборка
|
||
|
||
Требуется Go (на хосте без `go` в PATH — экспорт вручную):
|
||
|
||
```bash
|
||
export PATH=/opt/data/.local/go/bin:$PATH # локальный Go-тулчейн
|
||
go version # go1.25.0 linux/amd64
|
||
```
|
||
|
||
### Обычная сборка (текущая платформа)
|
||
|
||
```bash
|
||
cd /opt/data/src/ratatoskr-go
|
||
make build # go build -ldflags="-s -w" -o ratatoskr ./cmd/ratatoskr/
|
||
./ratatoskr -version
|
||
```
|
||
|
||
### Кросс-сборка под linux/amd64 и windows/amd64 (только эти две платформы)
|
||
|
||
```bash
|
||
make cross
|
||
# → ratatoskr-linux-amd64
|
||
# → ratatoskr-windows-amd64.exe (Windows-машина Камиля)
|
||
```
|
||
|
||
`Makefile` вшивает в бинарь версию (ldflag):
|
||
|
||
```make
|
||
GIT_SHA := $(shell git rev-parse --short HEAD || echo dev)
|
||
GOFLAGS ?= -ldflags="-s -w -X main.version=commit-$(GIT_SHA) -X main.updateToken=$(UPDATE_TOKEN)"
|
||
```
|
||
|
||
- `main.version` = `commit-<sha7>` — та же версия, под которой бинарь публикуется в Gitea Packages.
|
||
- `main.updateToken` = токен read:package для автообновления (см. ниже). Если не задан
|
||
(`UPDATE_TOKEN ?= ""`) — пустая встройка, тогда обновление берёт `update.token` из конфига.
|
||
|
||
**Обычный `make build`/`make cross` окно НЕ включает** — Fyne-окно требует cgo и
|
||
C-компилятора. Headless-бинарь (без окна) собирается и в CI, и локально; UI-бинарь
|
||
с окном — только локально (см. ниже).
|
||
|
||
### Локальная сборка UI-бинаря (Windows, Fyne-окно) + публикация в Gitea
|
||
|
||
CI headless: `CGO_ENABLED=0` и публикует только `ratatoskr-linux-amd64`
|
||
(см. `.gitea/workflows/ci.yaml`). Windows-бинарь с окном собирается на Windows-машине
|
||
и публикуется скриптом:
|
||
|
||
```bash
|
||
# 1) Секреты (git-ignored): скопировать и заполнить
|
||
cp scripts/.gitea-creds.example scripts/.gitea-creds
|
||
# GITEA_TOKEN=write:packages-токен
|
||
# UPDATE_TOKEN=read:package-токен
|
||
# GIT_MAIN_URL=http://gitea.hal9000.home
|
||
|
||
# 2) Убедиться, что gcc (MinGW) в PATH и CGO_ENABLED=1 — см. память
|
||
# `mem:toolchain/cgo-winlibs-gcc`
|
||
|
||
# 3) Запустить (HEAD должен быть тем коммитом, что в main):
|
||
powershell -ExecutionPolicy Bypass -File scripts/build-publish-ui.ps1
|
||
```
|
||
|
||
Скрипт собирает `ratatoskr-windows-amd64.exe` с `-X main.version=commit-<sha7>` и
|
||
`-X main.updateToken=...`, пишет companion-файлы `.version`/`.sha256` и публикует все
|
||
три в `.../generic/ratatoskr/commit-<sha7>/`. В конце сам перечитывает `.sha256`/`.version`
|
||
из Gitea и сверяет (контракт U4) — как проверка в разделе «Валидация опубликованной версии».
|
||
|
||
**Как на самом деле публикуется рабочий бинарь:** локальный `go build` токен НЕ вшивает
|
||
(значение секрета есть только у CI и в git-ignored `scripts/.gitea-creds`). Запушь `main` —
|
||
CI сам соберёт headless-linux со вшитым `TC_UPDATE_TOKEN` и загрузит его в Gitea Packages;
|
||
Windows-бинарь с UI публикуется локальным скриптом (токен read:package в `scripts/.gitea-creds`).
|
||
Так соблюдён least-privilege: read-токен не светится в репозитории/на хосте CI.
|
||
|
||
### Проверки перед сборкой
|
||
|
||
```bash
|
||
make test # go test ./... -v -count=1 -timeout 120s
|
||
make vet # go vet ./...
|
||
```
|
||
|
||
### Прогон
|
||
|
||
```bash
|
||
make run # build + ./ratatoskr -config config.yaml
|
||
```
|
||
|
||
## CI (.gitea/workflows/ci.yaml)
|
||
|
||
- **Job `test`**: checkout → setup-go 1.25 → `go test ./...` → `go vet ./...`.
|
||
Работает в headless-режиме (`CGO_ENABLED=0`, без Fyne/glfw).
|
||
- **Job `build-and-package`** (linux/amd64, без артефактов): собирает **headless**-бинарь
|
||
`ratatoskr-linux-amd64` со вшитым `-X main.updateToken=${secrets.TC_UPDATE_TOKEN}` и
|
||
**публикует в Gitea Packages** — только на `main`, сразу в версию `commit-<sha7>/`
|
||
(псевдо-версия `latest` НЕ используется — см. автообновление). Плюс companion-файлы
|
||
`<file>.version` и `<file>.sha256`.
|
||
|
||
**Windows-бинарь с UI в CI НЕ собирается** (нужен cgo + C-компилятор). Он собирается
|
||
и публикуется локально скриптом `scripts/build-publish-ui.ps1` (см. «Локальная сборка UI-бинаря»).
|
||
|
||
Секреты/vars: `TC_GITEA_TOKEN` (write:packages), `TC_UPDATE_TOKEN` (read:package),
|
||
`GIT_MAIN_URL`.
|
||
|
||
## Конфигурация (config.yaml)
|
||
|
||
Минимальный рабочий конфиг (порядок полей произвольный) и блок автообновления
|
||
(подробнее в `config.yaml.example`):
|
||
|
||
```yaml
|
||
telegram:
|
||
token: "..." # TG_TOKEN
|
||
chat_id: "..." # TG_CHAT_ID
|
||
opencode:
|
||
bin: "opencode"
|
||
config_dir: "./agents" # каталог, куда распаковываются встроенные агенты
|
||
paths:
|
||
db: "./ratatoskr.db" # дефолт; резолвится от каталога бинаря (не от cwd)
|
||
worktree: "./worktrees" # то же правило «всё рядом с .exe»
|
||
update:
|
||
enabled: true
|
||
base_url: "http://gitea.hal9000.home" # env UPDATE_BASE_URL; пуст = обновление ОТКЛЮЧЕНО
|
||
token: "${UPDATE_TOKEN}" # env UPDATE_TOKEN — фоллбэк, если не вшит ldflag
|
||
package: "ratatoskr"
|
||
check_interval: 24h
|
||
```
|
||
|
||
**Пути «всё рядом с .exe»:** относительные `db`/`worktree` резолвятся к каталогу
|
||
бинаря (`config.ExeDir()`), а НЕ к cwd. `config.yaml` ищется сначала рядом с бинарём,
|
||
фоллбэк — cwd. Абсолютные пути не трогаются.
|
||
|
||
## Команды бота
|
||
|
||
| Команда | Описание |
|
||
|---|---|
|
||
| `/start` | начать новую задачу |
|
||
| `/cancel` | отменить активную задачу |
|
||
| `/skip` | пропустить шаг |
|
||
| `/retry N` | повторить задачу N |
|
||
| `/status N` | статус задачи N |
|
||
| `/continue N` | продолжить |
|
||
| `/update` | проверить + применить обновление из Gitea Packages |
|
||
| `/status` | версия бинаря + есть ли доступное обновление |
|
||
| `/help` | справка по всем командам |
|
||
|
||
## Интеграция с opencode (субагенты)
|
||
|
||
Субагенты (analyst / dev / reviewer) запускаются через **headless** `opencode serve`
|
||
по **v2 HTTP API** (префикс `/api/*`). Требуемая версия opencode: **>= 1.18.18**
|
||
(сборки с v2 HTTP API). Старый бинарь, отвечающий только на `/global/health`,
|
||
не подходит: healthcheck падает с понятной ошибкой (класс O1).
|
||
|
||
Что делает обёртка (`internal/opencode`):
|
||
|
||
- **Хардпин модели.** При создании сессии в конфиге opencode ищется top-level
|
||
`"model"` (`internal/opencode/config.go`) и передаётся в `POST /api/session`
|
||
как `{"model":{providerID,id}}`. Это убирает зависимость от fallback-логики
|
||
opencode (которая молча выбирает «дефолтную» запись, если модель не задана).
|
||
- **Весь код резолва модели устойчив к этому классу проблем (класс O5 WARN):**
|
||
- если конфиг не читается / в нём нет `model` — в логи пишется warning;
|
||
- фактическая модель ответа (из финального assistant-сообщения) сравнивается
|
||
с ожидаемой; расхождение логируется как warning;
|
||
- конфиг, написанный по **старой v1-схеме** (`provider.X.npm` / `options`),
|
||
молча игнорируется v2 — обёртка этого не «чинит» сама, но предупреждает.
|
||
Правильный v2-вид провайдера — `api: { type:"aisdk", package, url }` и
|
||
`request.headers` вместо `options.headers`.
|
||
- **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt` →
|
||
durable admit), вердикт собирается из новых assistant-сообщений
|
||
(`GET .../message`); завершение ответа — сессия ушла из активных дренажей
|
||
(`GET .../active`) и появилось финальное assistant-сообщение, стабильное
|
||
несколько опросов подряд.
|
||
|
||
## Фазы аналитика
|
||
|
||
Аналитик (`internal/analyst`) возвращает JSON-вердикт с полем `phase`:
|
||
|
||
| phase | Смысл | Требования валидатора |
|
||
|---|---|---|
|
||
| `ask` | данных не хватает — задаёт уточняющие вопросы | есть `chat_reply` или `questions` |
|
||
| `propose` | черновик достаточно заполнен, вносятся правки | хотя бы одно изменённое поле (`title`/`goal`/`repos`/`why`/`ac`) |
|
||
| `ready` | черновик уже полный и готов как есть — менять нечего | изменённых полей не требуется |
|
||
| `abort` | тема не про код / не подходит | опционально `abort_reason` |
|
||
|
||
`ready` был добавлен как явная фаза для случая, когда аналитик видит полностью готовый
|
||
черновик и просто запускает задачу агенту-кодеру (раньше модель не могла это выразить и
|
||
возвращала пустой `propose`, который валидатор резал как «нет изменённых полей»).
|
||
В `internal/core` фазы `propose` и `ready` обрабатываются одинаково (применить черновик,
|
||
проверить репозитории, поставить `ready` и отдать резюме).
|
||
|
||
## Автообновление из Gitea Packages
|
||
|
||
Бинарь умеет сам себя обновлять из generic-пакета в Gitea. Модель:
|
||
**авто = только Check + уведомление** владельцу («доступна версия X, выполни /update»),
|
||
**применение — только по `/update`**.
|
||
|
||
- **Источник версий:** `{update.base_url}/api/packages/{owner}/generic/{package}`,
|
||
где `owner = "kamelion"` (константа `packageOwner` в коде), `package = "ratatoskr"`.
|
||
- **Каждая публикация — в конкретную версию `commit-<sha7>/`** (никакого `latest/`:
|
||
при параллельных PUT матрицы CI псевдо-версия `latest` разъезжается, и контрольные
|
||
суммы не сходятся — класс ошибки U4). Новейшая применимая версия ищется листингом:
|
||
фильтр `commit-*`, проба скачать бинарь платформы, берём версию с макс `id`.
|
||
- **Companion-файлы** рядом с бинарём в той же версии: `.<file>.version` (id версии)
|
||
и `<file>.sha256` (контрольная сумма). Verify всегда сверяет все три файла **одной** версии.
|
||
- **Update-хост ОТЛЕН от git-хоста:** `update.base_url` свой (env `UPDATE_BASE_URL`),
|
||
фоллбэка на `git.base_url` НЕТ. Пустой `update.base_url` полностью отключает обновление.
|
||
- **Токен доступа — только `read:package`, НЕ `git.token`** (least-privilege: update ≠ git).
|
||
Приоритет: вшитый ldflag `-X main.updateToken` (секрет CI `TC_UPDATE_TOKEN`) >
|
||
`update.token` из конфига (`UPDATE_TOKEN`). Заголовок: `Authorization: token <токен>`.
|
||
- **Применение (свап):** POSIX — exec-перезапуск процесса; Windows — мгновенный exec
|
||
невозможен (файл занят), поэтому `.new` применяется при следующем старте
|
||
(`ApplyPendingSwap` в main.go до подсистем). Резервный `.old` для отката.
|
||
|
||
### Как опубликовать новую версию
|
||
|
||
1. Внеси правки, коммитни, запуши в `main`.
|
||
2. CI соберёт `ratatoskr-linux-amd64` (headless) со вшитым `TC_UPDATE_TOKEN` и опубликует в
|
||
`.../generic/ratatoskr/commit-<sha7>/` (бинарь + `.version` + `.sha256`).
|
||
3. Если в этой версии есть изменения UI — дополнительно собрать и опубликовать
|
||
Windows-бинарь с окном: `scripts/build-publish-ui.ps1` (добавит
|
||
`ratatoskr-windows-amd64.exe` в ту же версию).
|
||
4. На обновляемой машине: бот сам уведомит о новой версии → `/update`.
|
||
|
||
Проверку автообновления после публикации гоняет владелец на своей
|
||
Windows-машине (не прогоняется агентом).
|
||
|
||
### Валидация опубликованной версии
|
||
|
||
```bash
|
||
B=http://gitea.hal9000.home
|
||
V=commit-<sha7>; f=ratatoskr-linux-amd64
|
||
curl -s -H "Authorization: token $TOKEN" -o /tmp/probe.bin "$B/api/packages/kamelion/generic/ratatoskr/$V/$f"
|
||
echo "actual: $(sha256sum /tmp/probe.bin)" # реальная сумма скачанного
|
||
curl -s -H "Authorization: token $TOKEN" "$B/api/packages/kamelion/generic/ratatoskr/$V/$f.version"
|
||
curl -s -H "Authorization: token $TOKEN" "$B/api/packages/kamelion/generic/ratatoskr/$V/$f.sha256"
|
||
# .version == $V и .sha256 == actual → версия целостна
|
||
```
|
||
|
||
## Классы ошибок
|
||
|
||
| Блок | Коды | Где |
|
||
|---|---|---|
|
||
| C | C1–C4 | `internal/config` |
|
||
| A | A1–A4 | `internal/analyst` |
|
||
| M | M1–M5 | `internal/chat` |
|
||
| 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` |
|
||
|
||
## Контракты (не ломать)
|
||
|
||
- `App.New(configPath, version, updateToken string)` — сигнатура: при правках обновлять
|
||
все вызовы в `internal/app/app_test.go` (иначе `go vet` падает).
|
||
- `packageOwner` — константа (`"kamelion"`) в `internal/app/app.go`: владелец пакета ==
|
||
владелец репо == создатель токена; инвариант на проект, НЕ плодить vars/ldflag/конфиг.
|
||
- `update.Updater` создаётся структурой `&update.Updater{...}` напрямую (конструктора
|
||
`NewUpdater` нет).
|
||
- `process_turn` (Python-версия) всегда возвращает 3 значения; `decide_fn` — кортеж
|
||
`(decision, err)`. Вердикты агентов — строгий JSON.
|
||
|
||
## Дополнительно
|
||
|
||
- Дизайн eval-suite (парсинг A / контракты B / качество C) — `../wiki/concepts/ratatoskr-evals.md`.
|
||
- Прочие команды/go-модель — см. скилл `ratatoskr-development`. |