Files
ratatoskr-go/README.md
ki.sagidullin 097491bef4 fix(ci): headless в CI, UI-бинарь собирается локально
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).
2026-08-20 17:51:25 +05:00

18 KiB
Raw Blame History

Ratatoskr-go

Ratatoskr на Go — оркестратор в единый статический бинарь + субагенты через opencode как внешний процесс. Переезд с 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 (классы C1C4)
  chat/               # мультиканальный Router; telegram — long-poll канал
  core/               # state-machine задач: /start /cancel /skip /retry N /status N /continue N
  analyst/            # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1A4)
  worker/             # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*)
  agents/             # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed
  opencode/           # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1O5)
  storage/            # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1S5)
  update/             # автообновление из Gitea Packages (классы U1U6)

Сборка

Требуется Go (на хосте без go в PATH — экспорт вручную):

export PATH=/opt/data/.local/go/bin:$PATH      # локальный Go-тулчейн
go version                                     # go1.25.0 linux/amd64

Обычная сборка (текущая платформа)

cd /opt/data/src/ratatoskr-go
make build          # go build -ldflags="-s -w" -o ratatoskr ./cmd/ratatoskr/
./ratatoskr -version

Кросс-сборка под linux/amd64 и windows/amd64 (только эти две платформы)

make cross
# → ratatoskr-linux-amd64
# → ratatoskr-windows-amd64.exe  (Windows-машина Камиля)

Makefile вшивает в бинарь версию (ldflag):

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-машине и публикуется скриптом:

# 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.

Проверки перед сборкой

make test      # go test ./... -v -count=1 -timeout 120s
make vet       # go vet ./...

Прогон

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

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-машине (не прогоняется агентом).

Валидация опубликованной версии

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 C1C4 internal/config
A A1A4 internal/analyst
M M1M5 internal/chat
O O1O5 internal/opencode
S S1S5 internal/storage
W W1W5 internal/worker
E E1E4 internal/worker (репозитории)
R R1R6 internal/worker/review_errors.go (ревьюер)
U U1U6 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.