mirror of
https://github.com/oqyude/nixos.git
synced 2026-10-10 05:55:24 +03:00
Compare commits
48
Commits
c87ba277c4
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16644fcc0b | ||
|
|
7c9aa24779 | ||
|
|
a4984a6b37 | ||
|
|
02927a26bb | ||
|
|
bc5aad87da | ||
|
|
f3c50e90ba | ||
|
|
e7c0fde0b1 | ||
|
|
3c9c5100a8 | ||
|
|
75433e2af7 | ||
|
|
7fd1736f1d | ||
|
|
58333d0257 | ||
|
|
5e9641602a | ||
|
|
b2718fd1e7 | ||
|
|
534fa429e1 | ||
|
|
698a1afaf7 | ||
|
|
14c91e68a4 | ||
|
|
c73a698857 | ||
|
|
c8d4a12a73 | ||
|
|
c854b2cc6d | ||
|
|
22a19be1b6 | ||
|
|
99747849d3 | ||
|
|
b88c8ebce0 | ||
|
|
cc20ee637d | ||
|
|
95ba7c2903 | ||
|
|
b0191bc7d1 | ||
|
|
543fcc61d9 | ||
|
|
0b9ac53b71 | ||
|
|
b476cace8e | ||
|
|
2de80a356b | ||
|
|
fb56f6310b | ||
|
|
1c77ae658e | ||
|
|
509fd3dde0 | ||
|
|
958247b22c | ||
|
|
c05cc88843 | ||
|
|
d49fd5a358 | ||
|
|
417c7abda6 | ||
|
|
ac561815ed | ||
|
|
2be5b168ac | ||
|
|
eddf44fb02 | ||
|
|
caeb04142d | ||
|
|
1db2b0955b | ||
|
|
a8ddf9b8dc | ||
|
|
bc3566d80e | ||
|
|
0fdf6c965c | ||
|
|
5bccf7586d | ||
|
|
2912581b99 | ||
|
|
72b4bdfbd8 | ||
|
|
44b85e1ebc |
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"metaagent_version": "3.0.0",
|
||||||
|
"session_id": "metaagent-init-2026-10-09",
|
||||||
|
"target_repo": "S:/Git/nixos",
|
||||||
|
"goal": "Установить metaagent, перенести накопленные данные (AGENTS.md, docs/arch/*) в структуру .agent/.",
|
||||||
|
"project_type": "existing",
|
||||||
|
"phases": {
|
||||||
|
"init": "completed",
|
||||||
|
"analyse": "completed",
|
||||||
|
"roadmap": "completed",
|
||||||
|
"design": "skipped",
|
||||||
|
"decomposition": "completed",
|
||||||
|
"execution": "in_progress",
|
||||||
|
"metastate": "pending",
|
||||||
|
"handoff": "pending"
|
||||||
|
},
|
||||||
|
"tasks": [
|
||||||
|
{ "id": "T1", "title": "A1: mobile.nix импортирует несуществующий lib/xlib.nix", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T2", "title": "A2: убедиться, что nix flake check вообще запускается", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T3", "title": "A3: явная финальная политика nftables на VDS", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T4", "title": "B1: guard на несмонтированный носитель /mnt/services", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T5", "title": "B2: зафиксировать, что бэкапов в конфигурации нет", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T6", "title": "C1: вернуть расследование 3x-ui, потерянное при откате", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T7", "title": "C2: зафиксировать фактические версии панели и ядра 3x-ui", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T8", "title": "C3: убрать сервис автообновления 3x-ui", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T9", "title": "C4: записать, что ядро Xray — состояние панели, а не Nix", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T10", "title": "C5: решить судьбу reality443Forwarding", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T11", "title": "D1: пробросы роутера — главный недостающий инвариант", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T12", "title": "D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T13", "title": "D3: убрать мёртвое правило firewall на sapphira", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T14", "title": "E1: написать AGENTS.md в корне (с metaagent-шапкой)", "status": "completed", "origin": "user:direct" },
|
||||||
|
{ "id": "T15", "title": "E2: выбрать проверки, которые заменят половину инвариантов", "status": "pending", "origin": "user:direct" },
|
||||||
|
{ "id": "T16", "title": "E3: судьба 15 закомментированных модулей", "status": "pending", "origin": "user:direct" }
|
||||||
|
],
|
||||||
|
"last_updated": "2026-10-09T20:30"
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Analysis Report
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
- **Session ID:** `metaagent-init-2026-10-09`
|
||||||
|
- **Target repo:** `S:/Git/nixos`
|
||||||
|
- **Date:** 2026-10-09
|
||||||
|
- **Project type:** `existing` (NixOS-конфиг, 120 `.nix`, ~8.5k строк)
|
||||||
|
|
||||||
|
## 1. Общая информация
|
||||||
|
|
||||||
|
- **README:** одна строка без смысла (`"I'm a super newbie who just posted my stuff here. Now maybe about intermediate"`); функциональную роль README играет `AGENTS.md` (корень).
|
||||||
|
- **Лицензия:** не указана.
|
||||||
|
- **CI/CD:** отсутствует. `nix flake check` — единственная автоматическая защита, прогоняется вручную. `deploy/default.nix:27-29` — `checks = builtins.mapAttrs (... deployChecks)`, но они покрывают только deploy-сценарий.
|
||||||
|
- **Точка входа:** `flake.nix` → `nixosConfigurations.<attr>` (хосты) + `nixOnDroidConfigurations.<attr>` (Android). Реестр хостов: `configurations/default.nix:13-39`.
|
||||||
|
- **Система сборки:** Nix + NixOS flakes. Home-manager, sops-nix, disko, deploy-rs, grub2-themes, justray.
|
||||||
|
|
||||||
|
## 2. Стек технологий (existing)
|
||||||
|
|
||||||
|
| Компонент | Значение |
|
||||||
|
|---|---|
|
||||||
|
| Язык | Nix (`.nix`) |
|
||||||
|
| Фреймворк | NixOS modules + home-manager |
|
||||||
|
| База данных | PostgreSQL (в `modules/server/postgresql.nix`), sqlite (3x-ui) |
|
||||||
|
| Тестовый раннер | отсутствует (см. §5) |
|
||||||
|
| Пакетный менеджер | Nix (`nix flake`, `nix-env`, `nix profile`) |
|
||||||
|
| Линтер/форматтер | отсутствует (комментарий-density 38/120 файлов без комментариев — открытый вопрос 0.2) |
|
||||||
|
|
||||||
|
## 3. Архитектура (existing)
|
||||||
|
|
||||||
|
```
|
||||||
|
flake.nix
|
||||||
|
├── configurations/ ← реестр хостов (1 запись = 1 машина)
|
||||||
|
│ ├── default.nix ← hosts + xlib + mkSystem
|
||||||
|
│ ├── <host>.nix ← модульное тело хоста
|
||||||
|
│ └── hardware/<host>.nix
|
||||||
|
├── home/ ← home-manager (per device-type)
|
||||||
|
├── modules/
|
||||||
|
│ ├── options.nix ← кросс-модульные опции
|
||||||
|
│ ├── default.nix ← defaultModule + strictModule (для nix-on-droid)
|
||||||
|
│ ├── essentials/ ← packages, services, settings, ssh, shell, systemd-routines
|
||||||
|
│ └── <type>/ ← per-type: desktop/, server/, vds/, wsl/, containers/, termux/, other/
|
||||||
|
├── lib/
|
||||||
|
│ ├── mkSystem.nix ← nixosSystem + specialArgs(xlib, inputs)
|
||||||
|
│ └── xlib/ ← чистые данные: devices, dirs, helpers
|
||||||
|
├── overlays/, pkgs/, deploy/, secrets/ (sops)
|
||||||
|
└── .sops.yaml ← один age-ключ на secrets/<name>.(yaml|json|env|ini)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Паттерн:** модульный монолит с xlib-инъекцией (аналог dependency injection через `specialArgs`).
|
||||||
|
|
||||||
|
**Ключевые модули:**
|
||||||
|
|
||||||
|
| Модуль | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `configurations/default.nix:13-39` | Реестр хостов (7 entries); `mkXlib` в строке 50 |
|
||||||
|
| `configurations/<host>.nix` | Per-host: имя, тип, импорт модулей; 7 файлов |
|
||||||
|
| `lib/xlib/default.nix:38-77` | `mkXlib` — единственная точка сборки xlib |
|
||||||
|
| `lib/xlib/device.nix:12-41` | Закрытое множество device.types: { minimal, primary, secondary, server, vds, wsl, termux } |
|
||||||
|
| `lib/xlib/helpers.nix` | `mkBindMount`, `mkSystemdBind`, `mkServiceStorage`, `mkNtfsMount`, `mkExfatMount`, `mkTmpDirs`, `mkSymlinks` |
|
||||||
|
| `modules/options.nix` | Кросс-модульные опции: `host.builder.*`, `host."3x-ui".*` |
|
||||||
|
| `modules/default.nix:9-39` | `nixosModules.default` — импортируется на каждый NixOS-хост; `nixosModules.strict:40-53` — для nix-on-droid |
|
||||||
|
| `modules/essentials/` | `packages`, `services`, `settings`, `ssh`, `shell`, `systemd-routines` |
|
||||||
|
| `modules/users.nix` | Пользователь `oqyude` (uid 1000, sapphira=1001), sops-секреты, hostKey bootstrap |
|
||||||
|
| `modules/server/` | 20+ системных сервисов sapphira (см. `server/default.nix:imports`) |
|
||||||
|
| `modules/server/systemd.nix` | rsync oneshots с `requiresMountsFor` guard (единственный пример guard'а) |
|
||||||
|
| `modules/server/{nginx,coredns}.nix` | Reverse proxy + DNS для зон `zeroq.su` и `home.arpa` |
|
||||||
|
| `modules/containers/` | podman-контейнеры: 3x-ui (заморожен), tape-rotation, remnanode, kokoro-tts, openhands, remnawave-examples |
|
||||||
|
| `home/<type>.nix` | Home-manager per device-type; для `root` — без профиля |
|
||||||
|
| `home/modules/opencode.nix` | OpenCode CLI + systemd user services (см. R2 — `Service` vs `serviceConfig`) |
|
||||||
|
| `deploy/default.nix` | deploy-rs ноды: sapphira, otreca, rydiwo (НЕ atoridu/wsl/epral) |
|
||||||
|
|
||||||
|
## 4. Конвенции (existing)
|
||||||
|
|
||||||
|
- **Стиль:** Nix-форматирование, разные отступы в разных файлах (нет единого стандарта).
|
||||||
|
- **Импорты:** `imports = [ ./foo.nix ./bar.nix ];` или list-spread. `lib.optional` для условных импортов.
|
||||||
|
- **Типизация:** types из `lib.types` (например, `lib.types.str`, `lib.types.bool`, `lib.types.attrsOf`).
|
||||||
|
- **Обработка ошибок:** `throw` + literal-сообщения (например, `lib/xlib/device.nix:12-41` — throw со списком валидных типов).
|
||||||
|
- **Логирование:** не формализовано; rsync oneshots в `modules/server/systemd.nix` — единственный пример с `-v` + structured output.
|
||||||
|
|
||||||
|
## 5. Тесты (existing)
|
||||||
|
|
||||||
|
- **Команда запуска:** отсутствует. Единственная полуавтоматическая проверка — `nix flake check`.
|
||||||
|
- **Всего тестов:** 0
|
||||||
|
- **Пройдено:** N/A
|
||||||
|
- **Упало:** N/A
|
||||||
|
- **Пропущено:** N/A
|
||||||
|
- **Упавшие тесты:** N/A
|
||||||
|
|
||||||
|
Кандидаты на CI-проверки (открытый вопрос 11.2):
|
||||||
|
|
||||||
|
1. ни одного `:latest` в образах (grep по `image =`)
|
||||||
|
2. `nix flake check` зелёный — уже ловит A1
|
||||||
|
3. домены в `coredns.nix` ↔ vhost'ы в `nginx.nix` совпадают в обе стороны
|
||||||
|
4. для каждого потребителя `mkServiceStorage` каталог существует на `External`
|
||||||
|
5. последнее правило самописной nftables-цепочки явное
|
||||||
|
6. `listen.addr` — адрес интерфейса, а не сеть
|
||||||
|
7. все файлы в `secrets/` матчат `path_regex` из `.sops.yaml`
|
||||||
|
|
||||||
|
## 6. Базовая проверка (existing)
|
||||||
|
|
||||||
|
- **Сборка:** не проверена в этой сессии (нет `nix` в PATH, AGENTS.md ссылается на `nix flake check`).
|
||||||
|
- **Запуск:** N/A (NixOS-конфиг, не приложение).
|
||||||
|
- **Git status:** рабочее дерево было чистым после коммита `7c9aa24` (yaml frontmatter в AGENTS.md). 1 modified файл (AGENTS.md) — fixed.
|
||||||
|
|
||||||
|
## 7. Требования (N/A для existing)
|
||||||
|
|
||||||
|
## 8. Примечания
|
||||||
|
|
||||||
|
- **Проход по репозиторию 2026-10-05**: 120 `.nix`, ~8.5k строк. Подтверждённые
|
||||||
|
инварианты (1–6) зафиксированы в `.agent/rules/project-rules.md` (R1).
|
||||||
|
- **Слои 9–11** (home-manager, deploy, формат) перенесены в `.agent/roadmap/sources.md`
|
||||||
|
как открытые вопросы; см. §9.2, §10.1-10.4, §11.1-11.2 исходного `invariants.md`.
|
||||||
|
- **Шаблон инварианта** (4 оси: Утверждение, Где, Почему, Действие) — для новых
|
||||||
|
записей. Обратное: до правки `authelia.nix:51,108,109` шёл через хелпер
|
||||||
|
`sopsPath = name: "/run/secrets/${name}"`; `open-webui.nix:95`, `tape-rotation.nix:61`,
|
||||||
|
`remnawave.nix:61, 129, 136` — литеральный хардкод. См. ADR-0001.
|
||||||
|
- **Bootstrap-цикл ключа** (R3) — должен быть задокументирован, иначе при
|
||||||
|
переустановке хоста агент не выведет. Открытый вопрос 4.2.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Project State
|
||||||
|
# Auto-generated — updated by ANALYSE (initial) and METASTATE (on updates)
|
||||||
|
|
||||||
|
**Last updated:** 2026-10-09T20:30
|
||||||
|
**Session:** metaagent-init-2026-10-09
|
||||||
|
|
||||||
|
## Project Type
|
||||||
|
|
||||||
|
`existing` — NixOS-конфиг домашнего флота. 7 host-outputs (6 NixOS + 1 nix-on-droid).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Category | Technology |
|
||||||
|
|----------|-----------|
|
||||||
|
| Language | Nix |
|
||||||
|
| Framework | NixOS modules + home-manager |
|
||||||
|
| Database | PostgreSQL (sapphira), sqlite (3x-ui) |
|
||||||
|
| Test runner | none — `nix flake check` is the only guard |
|
||||||
|
| Package manager | Nix (flakes) |
|
||||||
|
| Linter/formatter | none |
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
Модульный монолит с xlib-инъекцией. `flake.nix` → `nixosConfigurations.<attr>` через `configurations/default.nix`. Каждый хост получает `xlib` (identity + dirs + helpers) через `specialArgs` в `lib/mkSystem.nix`. `modules/default.nix` импортирует `nixosModules.default` (все NixOS) или `nixosModules.strict` (только nix-on-droid).
|
||||||
|
|
||||||
|
```
|
||||||
|
configurations/ → 7 хостов (default, atoridu, rydiwo, otreca, sapphira, wsl, epral)
|
||||||
|
modules/ → essentials + per-type (desktop/server/vds/wsl/containers/termux)
|
||||||
|
home/ → home-manager per device-type
|
||||||
|
lib/xlib/ → чистые данные (devices, dirs, helpers)
|
||||||
|
deploy/ → deploy-rs ноды: sapphira, otreca, rydiwo
|
||||||
|
secrets/ → sops, один age-ключ
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Modules
|
||||||
|
|
||||||
|
| Module | Status | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| `configurations/default.nix` | existing | Реестр хостов + `mkXlib` |
|
||||||
|
| `lib/xlib/default.nix` | existing | `mkXlib` — единственная точка сборки xlib |
|
||||||
|
| `modules/default.nix` | existing | `defaultModule` (NixOS) + `strictModule` (nix-on-droid) |
|
||||||
|
| `modules/options.nix` | existing | Кросс-модульные опции |
|
||||||
|
| `modules/users.nix` | existing | Пользователь + sops + hostKey bootstrap |
|
||||||
|
| `modules/essentials/ssh.nix` | existing | openssh + hostKey |
|
||||||
|
| `modules/server/{nginx,coredns}.nix` | existing | Reverse proxy + DNS (`zeroq.su`, `home.arpa`) |
|
||||||
|
| `modules/server/postgresql.nix` | existing | PostgreSQL + `mkServiceStorage` (нужен B1 guard) |
|
||||||
|
| `modules/server/systemd.nix` | existing | rsync oneshots с `requiresMountsFor` |
|
||||||
|
| `modules/containers/3x-ui.nix` | frozen | Панель :latest, ядро Xray 26.7.x |
|
||||||
|
| `home/<type>.nix` | existing | Per-type home-manager |
|
||||||
|
| `home/modules/opencode.nix` | existing | OpenCode CLI + systemd user (см. R2) |
|
||||||
|
| `deploy/default.nix` | existing | deploy-rs: sapphira, otreca, rydiwo |
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
| Attr | hostname | device.type | deploy | stateVersion | Примечание |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `default` | nixos | minimal | — | — | Шаблон |
|
||||||
|
| `atoridu` | atoridu | primary | — (manual) | 26.05 | xanmod, mini-PC, без nixos-hardware |
|
||||||
|
| `rydiwo` | rydiwo | secondary | deploy-rs | 26.05 | Chuwi MiniBook, NTFS `lamet-drive` (mask=0000) |
|
||||||
|
| `otrecа` | otreca | vds | deploy-rs | 25.05 | VPS, Tailscale-only SSH, nftables (нужен A3) |
|
||||||
|
| `sapphira` | sapphira | server | deploy-rs | 25.05 | Домашний сервер, `firewall.enable = false` намеренно |
|
||||||
|
| `wsl` | wsl | wsl | — (manual) | 24.11 | WSL на vetymae (Windows 192.168.1.100) |
|
||||||
|
| `epral` | epral | termux | — | 24.05 | Android nix-on-droid, через `mobile.nix` |
|
||||||
|
|
||||||
|
## Services (sapphira, активные)
|
||||||
|
|
||||||
|
20+ системных сервисов + 6 контейнеров. Полный инвентарь в `tasks/manifest.json` (задачи A1-F). Все, использующие `mkServiceStorage`, нуждаются в B1 guard: postgresql, samba, homebox, gitea, navidrome, syncthing, uptime-kuma, immich, nextcloud, calibre-web, 3x-ui, tape-rotation.
|
||||||
|
|
||||||
|
## Network
|
||||||
|
|
||||||
|
```
|
||||||
|
LAN 192.168.1.0/24:
|
||||||
|
.20 sapphira (зашит в ~30 мест; см. вопрос 6.6)
|
||||||
|
.1 роутер (gateway)
|
||||||
|
.100 vetymae (Windows-хост с WSL)
|
||||||
|
|
||||||
|
Tailscale CGNAT 100.64.0.0/10:
|
||||||
|
100.64.0.0 = sapphira (назначен вручную, в 4 файлах)
|
||||||
|
100.86.62.4 = opencode на vetymae
|
||||||
|
100.106.21.39 = miniflux
|
||||||
|
|
||||||
|
Internet:
|
||||||
|
sapphira: пробросы роутера = 22, 80, 443, 8443 (xray), 22000 (syncthing)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisions in Effect
|
||||||
|
|
||||||
|
| ADR | Decision | Status |
|
||||||
|
|-----|----------|--------|
|
||||||
|
| ADR-0001 | sops-пути: `config.sops.secrets.<name>.path` (не литерал) | active |
|
||||||
|
|
||||||
|
Подробнее: `.agent/decisions/0001-sops-secrets-paths.md`.
|
||||||
|
|
||||||
|
## Testing Status
|
||||||
|
|
||||||
|
Тестов нет. Единственная защита — `nix flake check`. Кандидаты на CI-проверки (7 штук) — в `analysis-report.md §5`.
|
||||||
|
|
||||||
|
## Open Concerns
|
||||||
|
|
||||||
|
- **B1**: нет guard'а на несмонтированный `/mnt/services` — сервисы стартуют на пустой БД.
|
||||||
|
- **A3**: nftables на VDS без явной финальной политики — неявный accept.
|
||||||
|
- **C1–C5**: 3x-ui заморожен, но без формального ADR; `podman.autoPrune` + `:latest` = деградация без коммита.
|
||||||
|
- **2.2/5.5**: `vetymae` / `lamet` / `therima` / `soptur` в `dirs.nix` — природа неясна.
|
||||||
|
- **4.1/4.2**: root-SSH и bootstrap-цикл ключа — не задокументированы.
|
||||||
|
- **6.6**: `192.168.1.20` зашит в 30 мест — рефакторинг отложен.
|
||||||
|
|
||||||
|
Полный список — в `roadmap/sources.md` (открытые вопросы слоёв 0-11).
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# ADR-0001: sops-пути — только через `config.sops.secrets.<name>.path`
|
||||||
|
|
||||||
|
**Статус:** accepted
|
||||||
|
|
||||||
|
**Дата:** 2026-10-09
|
||||||
|
|
||||||
|
**Контекст:**
|
||||||
|
|
||||||
|
sops-nix материализует секреты на `/run/secrets/<attr>` по умолчанию.
|
||||||
|
Атрибут sops-блока (`config.sops.secrets.<attr>`) — единственный источник
|
||||||
|
истины для on-disk пути. Любой `path =` override на sops-блоке плюс хардкод
|
||||||
|
`"/run/secrets/<attr>"` в потребителе делает потребителя **молча**
|
||||||
|
сломанным: `nixos-rebuild` проходит, сервис стартует, файл читается — но
|
||||||
|
контент от прошлой версии или пустой. Симптом приходит из рантайма, не из CI.
|
||||||
|
|
||||||
|
До этой правки (см. «Обратное») в кодовой базе были хардкоды путей в 5
|
||||||
|
местах: `authelia.nix`, `open-webui.nix`, `tape-rotation.nix`, `remnawave.nix`.
|
||||||
|
В `remnawave.nix` тот же риск был двойной: путь хардкожен и в генераторе,
|
||||||
|
и в контейнере → расхождение двух копий = silent breakage.
|
||||||
|
|
||||||
|
**Рассматриваемые альтернативы:**
|
||||||
|
|
||||||
|
1. **A. `${config.sops.secrets.<attr>.path}`** — единая точка истины. Любой
|
||||||
|
`path =` override автоматически подхватывается потребителем.
|
||||||
|
2. **B. Хелпер `sopsPath = name: "/run/secrets/${name}"`** — был в `authelia.nix:51`
|
||||||
|
до правки. Компактнее в написании, но тащит хардкод `/run/secrets/` в API
|
||||||
|
и делает невозможным `path =` override без правки потребителя.
|
||||||
|
3. **C. `let envFile = "/run/secrets/${name}"; in { … }` для композитных
|
||||||
|
случаев** — для `remnawave.nix`, где один и тот же env-файл читается и
|
||||||
|
генератором, и контейнером. Используется, но с явным комментарием.
|
||||||
|
|
||||||
|
**Решение:** выбран вариант **A** для прямого доступа к одному секрету и
|
||||||
|
вариант **C** для композитных env-файлов в `remnawave.nix` (с комментарием).
|
||||||
|
|
||||||
|
**Обоснование:**
|
||||||
|
|
||||||
|
- **Единая точка истины.** Атрибут sops-блока — единственное место, где
|
||||||
|
определяется on-disk путь. Потребитель ссылается на `${config.sops.secrets.<attr>.path}`.
|
||||||
|
- **Симметрия с `mkUserSecret`.** `users.nix:33-41` уже использует этот
|
||||||
|
паттерн (`config.sops.secrets.<name>.path`) — единый стиль по репо.
|
||||||
|
- **Без хелпера.** `sopsPath = name: "/run/secrets/${name}"` выглядит
|
||||||
|
компактнее, но скрывает хардкод `/run/secrets/`. Когда кто-то добавит
|
||||||
|
`path = "/var/lib/..."` в sops-блок, потребитель через хелпер молча
|
||||||
|
сломается.
|
||||||
|
- **Двухкопийный env-файл в remnawave.nix** — композитный случай, где
|
||||||
|
`let`-биндинг в одном scope с комментарием «не дублировать литерал»
|
||||||
|
делает связь явной.
|
||||||
|
|
||||||
|
**Последствия:**
|
||||||
|
|
||||||
|
- Позитивные:
|
||||||
|
- `nix flake check` (после T1, T2) ловит несоответствие путей в compile-time.
|
||||||
|
- `path =` override в sops-блоке не ломает потребителя молча.
|
||||||
|
- Единый стиль по репо: 5 мест исправлены, новые пишутся по образцу.
|
||||||
|
- Негативные:
|
||||||
|
- Длиннее в написании, чем `"/run/secrets/${name}"`.
|
||||||
|
- Риски:
|
||||||
|
- Если кто-то добавит нового потребителя sops и напишет литерал
|
||||||
|
`/run/secrets/<name>` — молчаливое расхождение. Защита: код-ревью +
|
||||||
|
кандидат в CI-проверки (`secrets/missing-paths.nix` или grep).
|
||||||
|
|
||||||
|
**Invariant:**
|
||||||
|
|
||||||
|
> Любой потребитель sops-секрета в `modules/` ссылается на путь через
|
||||||
|
> `${config.sops.secrets.<attr>.path}`, а не через литерал
|
||||||
|
> `"/run/secrets/<attr>"`. Атрибут sops-блока — единственный источник
|
||||||
|
> истины для on-disk пути.
|
||||||
|
|
||||||
|
Зафиксировано в `.agent/rules/project-rules.md` (R1.7) + `analysis-report.md §8`.
|
||||||
|
|
||||||
|
**Обратное (где было сломано до этой правки):**
|
||||||
|
|
||||||
|
- `modules/server/authelia.nix:51,108,109` — через хелпер `sopsPath = name: "/run/secrets/${name}"`.
|
||||||
|
- `modules/containers/open-webui.nix:95` — литеральный хардкод.
|
||||||
|
- `modules/containers/tape-rotation.nix:61` — литеральный хардкод.
|
||||||
|
- `modules/containers/remnawave.nix:61, 129, 136` — литеральный хардкод, в двух местах (генератор + контейнер).
|
||||||
|
|
||||||
|
**Затронутые файлы (после правки):**
|
||||||
|
|
||||||
|
- `modules/server/authelia.nix:107-108`
|
||||||
|
- `modules/containers/open-webui.nix:101`
|
||||||
|
- `modules/containers/tape-rotation.nix:63`
|
||||||
|
- `modules/containers/remnawave.nix:16, 70, 138, 145` — `let`-биндинг для композитного env-файла (вариант C).
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"version": "3.0.0",
|
||||||
|
"updated_at": "2026-10-09T20:30",
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"id": "0001",
|
||||||
|
"title": "sops-пути — только через config.sops.secrets.<name>.path",
|
||||||
|
"status": "accepted",
|
||||||
|
"date": "2026-10-09",
|
||||||
|
"file": ".agent/decisions/0001-sops-secrets-paths.md",
|
||||||
|
"tags": ["sops", "secrets", "security", "invariant"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Roadmap Sources
|
||||||
|
|
||||||
|
Источники задач для фазы DECOMPOSITION. Собрано при проходе по репозиторию
|
||||||
|
2026-10-05, ответы владельца учтены. Полный Q&A-источник (с разделами «Вопрос»,
|
||||||
|
«Факт», «Риск», «Кандидат») восстановим из git-истории:
|
||||||
|
`git log -p docs/arch/invariants.md | less` (последний коммит, где Q&A был полным).
|
||||||
|
|
||||||
|
Пометки: `[!]` — найденный дефект, не вопрос. `[?]` — не смог определить по коду.
|
||||||
|
`[✓]` — отвечено владельцем 2026-10-05.
|
||||||
|
|
||||||
|
## Статус ответов (2026-10-05)
|
||||||
|
|
||||||
|
Отвечено: **2.1, 5.2, 6.1, 6.3, 6.5, 7.1, 7.2** (7 пунктов). Остальные ждут
|
||||||
|
ответа (таблица ниже). Ключевое из ответов:
|
||||||
|
|
||||||
|
- **6.5** — `100.64.0.0` не «сетевой адрес вместо интерфейса»: Tailscale-адрес
|
||||||
|
sapphira, назначен вручную. См. ADR R1.4.
|
||||||
|
- **6.3** — `firewall.enable = false` на sapphira не недосмотр: граница на
|
||||||
|
роутере (5 портов). См. ADR R1.3 + задачу D1.
|
||||||
|
- **7.2** — 3x-ui рабочий, откат осознанный. Состояние «заморожено», не сломано.
|
||||||
|
См. ADR R1.5 + задачи C1–C5.
|
||||||
|
- **5.2** — подтверждённая дыра в защите данных → задача B1.
|
||||||
|
|
||||||
|
## Сводка подтверждённых инвариантов
|
||||||
|
|
||||||
|
См. `.agent/rules/project-rules.md` (R1.1–R1.7) — закреплено в `AGENTS.md` §«Подтверждённые инварианты» до мерджа.
|
||||||
|
|
||||||
|
| # | Пункт | Краткая формулировка | Задача |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Все `outputs` флейка вычисляются | A1: правка `lib/xlib.nix` → `lib/xlib`; закрепить через `nix flake check` | A1 |
|
||||||
|
| 2 | External-диск монтируется до сервисов | mkServiceStorage + bind без guard'а → сервис стартует на пустой БД | B1 |
|
||||||
|
| 3 | Сетевая граница sapphira = роутер | 5 портов: 22, 80, 443, 8443, 22000; `firewall.enable = false` намеренно | D1 |
|
||||||
|
| 4 | `100.64.0.0` = Tailscale sapphira | Назначен вручную; в 4 файлах | D2 |
|
||||||
|
| 5 | 3x-ui заморожен | Панель на latest; ядро Xray на 26.7.x; миграция 26.9 провалена | C1–C5 |
|
||||||
|
| 6 | nftables на VDS — явная финальная политика | Сейчас ruleset без финального правила + конфликт с `firewall.*` | A3 |
|
||||||
|
| 7 | sops-пути — через `config.sops.secrets.<name>.path` | Любой `path =` override на sops-блоке делает хардкод-потребителя молча сломанным | ADR-0001 |
|
||||||
|
|
||||||
|
## Открытые вопросы (слои 0–8)
|
||||||
|
|
||||||
|
Самые важные — выделены. Источник для новых задач в `.agent/tasks/manifest.json`.
|
||||||
|
|
||||||
|
| ID | Вопрос | Что блокирует |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.1 | Восстанавливать ли migration notes, удалённые в `22a19be`? | C1: реконструкция заметки 3x-ui |
|
||||||
|
| 0.2 | Комментарий-density 38/120 файлов без комментариев — нормально? | Стиль модулей |
|
||||||
|
| 0.3 | 15 закомментированных модулей: удалить или хранить как референс? | E3: чистота кода |
|
||||||
|
| 0.4 | README пустой, todo.md нет — норма? | E1: AGENTS.md/README |
|
||||||
|
| 1.3 | Лишние inputs в flake (`justray`, `nix-minecraft`, `proxy-suite`)? | Чистота flake |
|
||||||
|
| 1.5 | `nix-systems` через `follows` — оптимизация размера lock | Документация |
|
||||||
|
| **2.2** | **`vetymae` / `lamet` / `therima` / `soptur` — те же машины или хосты вне реестра?** | **DNS/nginx/identity** |
|
||||||
|
| 2.3 | sapphira uid=1001: блокер ли использование `/mnt/archive`/`/mnt/mobile`? | Миграция ФС |
|
||||||
|
| **2.5** | **stateVersion 24.05 / 24.11 / 25.05 / 26.05 — намеренный дрейф?** | **Миграции** |
|
||||||
|
| 2.6 | Есть ли escape hatch для per-host отличий в xlib? | Архитектура |
|
||||||
|
| 2.7 | `devices.termux` без NixOS-хоста — закрытый список | Документация |
|
||||||
|
| 3.2 | `any.nix` (minimal) нужны home-manager + sops + disko? | Минималка |
|
||||||
|
| **4.1** | **Как root получает доступ по SSH — authorizedKeys в коде нет** | **deploy, безопасность** |
|
||||||
|
| **4.2** | **Как разрешается цикл «ключ в секрете, а нужен для расшифровки»?** | **bootstrap, recovery** |
|
||||||
|
| 4.3 | Все файлы в `secrets/` покрыты `path_regex`? | sops |
|
||||||
|
| 4.4 | Как подключается вторая машина / второй человек при одном age-ключе? | sops, scale |
|
||||||
|
| 4.5 | `users.nix:87` — личный ключ или общий «ключ от деплоя»? | Безопасность |
|
||||||
|
| 5.1 | `/mnt/services` mode 0777 — осознанно? | Безопасность |
|
||||||
|
| 5.3 | NFS выключен, Samba работает — миграция? | Сетевые сервисы |
|
||||||
|
| 5.4 | NTFS-том `lamet-drive` `mask=0000` — что на нём? | Семантика |
|
||||||
|
| 5.5 | `therima` / `vetymae` / `soptur` в dirs.nix — реально смонтированы? | Семантика |
|
||||||
|
| 5.6 | Где бэкапы БД и 3x-ui? | B2 |
|
||||||
|
| 6.6 | `192.168.1.20` зашит в 30 мест — константа? | Рефакторинг |
|
||||||
|
| 6.7 | DNS ↔ сервисы — как ловим рассинхрон? | Документация, CI |
|
||||||
|
| **6.8** | **Публичные IP + SSH-алиасы в `home/termux.nix` — карта «хост → адреса» нужна?** | **Архитектура** |
|
||||||
|
| 6.9 | Какой путь REALITY правильный сейчас? | C5 |
|
||||||
|
| 7.4 | Почему не публиковать весь диапазон 14380-15379? | 3x-ui |
|
||||||
|
| 8.2 | `lamet.opencodes` → `:6061` (порт miniflux) — ошибка? | nginx |
|
||||||
|
| 8.4 | `onlyoffice` после трёх регрессов — работает? | Статус сервиса |
|
||||||
|
| 8.5 | Что слушает `:3002` (`/whiteboard` nextcloud)? | Карта сервисов |
|
||||||
|
| 8.6 | Бэкапы вне Nix — записать | Документация |
|
||||||
|
|
||||||
|
## Слой 9. home-manager
|
||||||
|
|
||||||
|
**9.1** `home/home.nix:52-57` — для пользователя импортируется
|
||||||
|
`home/${xlib.device.type}.nix`; для `root` — без профиля (строка 51).
|
||||||
|
**Вопрос:** почему у `root` нет home-профиля — сознательно?
|
||||||
|
**Кандидат:** `home/<type>.nix` = единственный источник «что есть на этом хосте»
|
||||||
|
для пользователя; добавление пакета в новый тип = правильный файл, а не
|
||||||
|
`home/default.nix`.
|
||||||
|
|
||||||
|
**9.2 [!]** `home/home.nix:28-43` для headless-хостов: `xdg.userDirs.* = null` и
|
||||||
|
`createDirectories = false`, при этом `lib/xlib/dirs.nix:26` обещает
|
||||||
|
`music-library = "${user-home}/Music"`.
|
||||||
|
**Вопрос:** кто создаёт `~/Music` и `~/Storage`? `createDirectories = false`
|
||||||
|
означает, что home-manager их не создаст, а `dirs.nix` на них ссылается.
|
||||||
|
**Риск:** на headless-хосте путь в конфиге есть, а каталога нет → тихий сбой
|
||||||
|
сервиса, который туда пишет.
|
||||||
|
|
||||||
|
**9.3 [!]** `home/modules/opencode.nix:339-350` (`c73a698`): в home-manager нельзя
|
||||||
|
писать `serviceConfig = { ... }` — рендерится литеральная секция `[serviceConfig]`,
|
||||||
|
которую systemd молча игнорирует. Закреплено в R2.
|
||||||
|
|
||||||
|
**9.4** `linger = true` добавлен ради `opencode-web` (`users.nix:71-75`) и включён
|
||||||
|
**для всех** хостов.
|
||||||
|
**Кандидат:** «user-сервисы переживают logout на всех хостах» — закрепить, потому
|
||||||
|
что это неочевидное поведение, влияющее на ресурсы и на безопасность.
|
||||||
|
|
||||||
|
**9.5** `home/modules/opencode.nix:286-303` — `opencode.web` слушает `0.0.0.0:4096`
|
||||||
|
(комментарий: nginx проксирует `127.0.0.1:4096`), и nginx на sapphira ходит туда
|
||||||
|
же по Tailscale у двух других хостов (см. 8.3).
|
||||||
|
**Кандидат:** `0.0.0.0` в `opencode.web` — обязательное условие для внешнего
|
||||||
|
доступа через `opencodes.*`; пароль приходит из sops-секрета `opencode_server`.
|
||||||
|
|
||||||
|
**9.6** Секреты opencode приходят в `~/.config/opencode/server.env` (dotenv),
|
||||||
|
`~/.local/share/opencode/auth.json` и `account.json` (json, `key = ""`).
|
||||||
|
**Кандидат:** эти три файла перезаписываются sops при каждой активации — ручные
|
||||||
|
правки в них теряются.
|
||||||
|
|
||||||
|
## Слой 10. deploy и проверка
|
||||||
|
|
||||||
|
**10.1** `deploy/default.nix:20-24` — цели: `sapphira` (server), `otrecа` (vds),
|
||||||
|
`rydiwo` (ноутбук). **Нет** `atoridu` (основной десктоп), `wsl`, `epral`.
|
||||||
|
**Вопрос:** почему не деплоится десктоп? И безопасно ли пересобирать ноутбук
|
||||||
|
`rydiwo` по SSH (он может быть выключен/на другом Wi-Fi)?
|
||||||
|
**Кандидат:** `deploy-rs` = только серверы + ноутбук; десктоп и WSL обновляются
|
||||||
|
вручную.
|
||||||
|
|
||||||
|
**10.2** `deploy/default.nix:18-19` — `sshUser = "oqyude"`, `user = "root"`.
|
||||||
|
См. 4.1: root-доход по SSH не описан в конфигурации.
|
||||||
|
|
||||||
|
**10.3** `deploy/default.nix:27-29` — `checks = builtins.mapAttrs (... deployChecks)`.
|
||||||
|
**Вопрос:** `nix flake check` реально проходит сейчас?
|
||||||
|
|
||||||
|
**10.4** CI нет, `flake check` не запускается автоматически.
|
||||||
|
**Кандидат:** минимальный локальный набор перед коммитом:
|
||||||
|
`nix flake check && nix build .#nixosConfigurations.<хост>.config.system.build.toplevel --dry-run`.
|
||||||
|
|
||||||
|
## Слой 11. Формат
|
||||||
|
|
||||||
|
**11.1** Где будет жить итог: `AGENTS.md` в корне (читается агентом всегда),
|
||||||
|
`docs/arch/map.md` (карта хостов/сервисов), `docs/arch/invariants.md` (этот файл).
|
||||||
|
**Кандидат:** этот файл после ответов превращается в `.agent/roadmap/sources.md`
|
||||||
|
с колонкой «ответ» и становится источником для `.agent/context/project-state.md`;
|
||||||
|
`.agent/context/project-state.md` — сжатая выжимка, без подробностей. (Сделано.)
|
||||||
|
|
||||||
|
**11.2** Какие инварианты можно превратить в автоматическую проверку (тогда они
|
||||||
|
перестанут «забываться»): 7 кандидатов в `analysis-report.md §5`.
|
||||||
|
**Вопрос:** какие из этих проверок ты хочешь, а какие — лишний CI?
|
||||||
|
|
||||||
|
## Шаблон инварианта
|
||||||
|
|
||||||
|
Этот шаблон — для добавления новых инвариантов в `.agent/rules/project-rules.md`
|
||||||
|
(и для зеркалирования в `AGENTS.md`). Та же 4-осевая структура используется,
|
||||||
|
чтобы вытащить «невидимое знание владельца» из существующего кода в явное
|
||||||
|
утверждение.
|
||||||
|
|
||||||
|
1. **Утверждение** — что именно верно и нельзя менять без осознанного
|
||||||
|
решения. Один-два абзаца, никаких «может быть».
|
||||||
|
2. **Где** — конкретные файлы и строки. Агент не должен угадывать.
|
||||||
|
3. **Почему** — что происходит при нарушении. Лучше всего — сценарий
|
||||||
|
(rebuild / рантайм), а не абстрактный риск.
|
||||||
|
4. **Действие** — task id в `manifest.json`, ссылка на коммит, или явное
|
||||||
|
«закреплено автоматической проверкой (см. analysis-report.md §5)».
|
||||||
|
|
||||||
|
Дополнительные поля по необходимости: «ловушка» (выглядит сломанным,
|
||||||
|
намеренно), «обратное» (где это уже было сломано раньше), «как проверить»
|
||||||
|
(grep / CI).
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Project Rules
|
||||||
|
|
||||||
|
Правила, которым агент обязан следовать во всех фазах. Источник: старый
|
||||||
|
`AGENTS.md` (накоплен при проходе по репозиторию 2026-10-05, ответы владельца
|
||||||
|
учтены 2026-10-05). Дополнения и уточнения — через `/adr`.
|
||||||
|
|
||||||
|
## Обязательные правила
|
||||||
|
|
||||||
|
### R1. Не ломать подтверждённые инварианты
|
||||||
|
|
||||||
|
1. **Все `outputs` флейка должны вычисляться.** `configurations/mobile.nix:12`
|
||||||
|
импортировал несуществующий `lib/xlib.nix` — был сломан, `epral` не
|
||||||
|
собирался. Закреплено через `nix flake check`.
|
||||||
|
2. **Носитель данных (`/home/oqyude/External`) обязан быть смонтирован** до
|
||||||
|
старта `postgresql`, `n8n`, `samba`, `homebox`, `minecraft`, `3x-ui`,
|
||||||
|
`tape-rotation`. `mkServiceStorage` даёт `bind,x-systemd.automount,nofail`
|
||||||
|
— без guard'а сервис стартует на пустой БД. Задача `B1` в `manifest.json`.
|
||||||
|
3. **Сетевая граница sapphira — роутер.** `firewall.enable = false` намеренно.
|
||||||
|
Роутер пробрасывает ровно 5 портов: **443, 80, 22000 (syncthing),
|
||||||
|
8443 (xray), 22 (ssh)**. `nginx.nix:225` (`allowedTCPPorts = [80 443]`) мёртв.
|
||||||
|
`openFirewall`/`allowedTCPPorts` на sapphira не имеют эффекта.
|
||||||
|
4. **`100.64.0.0` = Tailscale-адрес sapphira**, назначен вручную. Не сеть, не
|
||||||
|
ошибка. Используется в `nginx.nix`, `nextcloud.nix` (`trusted_proxies`),
|
||||||
|
`vds/systemd.nix`, `vds/nginx.nix`. При смене — править 4 файла.
|
||||||
|
5. **3x-ui заморожен.** Панель на последней версии (образ `:latest`),
|
||||||
|
ядро Xray на 26.7.x. Миграция на 26.9.x провалена. Обходные скрипты
|
||||||
|
(timer, migrateScript) отключены осознанно. **Не** обновлять ядро через
|
||||||
|
панель без записи в `decisions/` или `notes/`.
|
||||||
|
6. **nftables на VDS требует явной финальной политики.** Текущий ruleset
|
||||||
|
(`vds.nix:73-91`) — без явного последнего правила и без `policy` → неявный
|
||||||
|
accept. На otreca одновременно `nftables.enable = true` и `firewall.*` —
|
||||||
|
проверить, кто реально владеет ruleset'ом, перед правкой.
|
||||||
|
7. **sops-пути — через `config.sops.secrets.<name>.path`.** Любой
|
||||||
|
`path =` override на sops-блоке делает хардкод-потребителя молча
|
||||||
|
сломанным: rebuild зелёный, сервис стартует, контент пустой. См. ADR-0001.
|
||||||
|
|
||||||
|
### R2. home-manager `Service` ≠ `serviceConfig`
|
||||||
|
|
||||||
|
`home/modules/opencode.nix:339-350` (`c73a698`): в home-manager нельзя писать
|
||||||
|
`serviceConfig = { ... }` — рендерится литеральная секция `[serviceConfig]`,
|
||||||
|
которую systemd молча игнорирует («Unknown section 'serviceConfig'. Ignoring.»).
|
||||||
|
Правильно: `systemd.user.services.opencode-web.Service = { ... }`. В home-manager
|
||||||
|
cgroup-опции (`MemoryHigh`, `OOMScoreAdjust`, …) пишутся в
|
||||||
|
`systemd.user.services.<name>.Service`, **не** в `serviceConfig`. Ошибка
|
||||||
|
не диагностируется — она просто не применяется.
|
||||||
|
|
||||||
|
### R3. Sops-цикл ключа задокументировать
|
||||||
|
|
||||||
|
`/etc/ssh/id_ed25519` одновременно: `hostKeys` для sshd, `sops.age.sshKeyPaths`
|
||||||
|
для расшифровки, цель `ssh_key_private_known`, цель `ssh_key_public_host`.
|
||||||
|
Как разворачивается на чистой машине — **одноразовый bootstrap**. Должен быть
|
||||||
|
задокументирован, иначе при переустановке хоста агент не выведет.
|
||||||
|
|
||||||
|
### R4. Перед деплоем External-диска — `findmnt`
|
||||||
|
|
||||||
|
Перед рестартом сервисов, использующих `mkServiceStorage` (postgresql, samba,
|
||||||
|
homebox, gitea, navidrome, syncthing, uptime-kuma, immich, nextcloud,
|
||||||
|
calibre-web, 3x-ui, tape-rotation):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
findmnt /home/oqyude/External
|
||||||
|
findmnt /mnt/services
|
||||||
|
```
|
||||||
|
|
||||||
|
До реализации guard'а (задача B1) — это единственная защита от старта на
|
||||||
|
пустой БД.
|
||||||
|
|
||||||
|
### R5. Проверка целостности sops-секретов
|
||||||
|
|
||||||
|
Любая правка `users.nix` или потребителя sops-секрета требует:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sops --version
|
||||||
|
nix build .#nixosConfigurations.<хост>.config.system.build.toplevel --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Расшифровка sops-секретов зависит от `/etc/ssh/id_ed25519` (см. R3).
|
||||||
|
Циклическая зависимость — см. «Где НЕ лезть без ответа».
|
||||||
|
|
||||||
|
## Ловушки (выглядит сломанным, намеренно)
|
||||||
|
|
||||||
|
Прежде чем чинить — проверить этот список. Здесь лежат решения, которые
|
||||||
|
иначе «поправляются» обратно и ломают рабочую систему.
|
||||||
|
|
||||||
|
| Где | Что выглядит ошибкой | На самом деле |
|
||||||
|
|---|---|---|
|
||||||
|
| `server.nix:130` | `firewall.enable = false` при 20 сервисах на `0.0.0.0` | Роутер фильтрует, см. R1.3 |
|
||||||
|
| `mobile.nix:95`, `wsl.nix:59` | `stateVersion` 24.05 / 24.11 vs 26.05 | Каждый хост зафиксирован на своей версии |
|
||||||
|
| `users.nix:66` | `uid = if hostname == "sapphira" then 1001 else …` | Костыль под 1000 = удалённый `yuyus`; удалять только после миграции ФС |
|
||||||
|
| `3x-ui.nix:54` | `image = …:latest` | Панель намеренно latest; ядро Xray — на 26.7.x |
|
||||||
|
| `3x-ui.nix:33-35` | `reality443Forwarding = true` на VDS | Следствие отката `c8d4a12`; смысл утрачен, см. задачу C5 |
|
||||||
|
| `server/default.nix:33-47` | 15 закомментированных модулей | Отключены осознанно, см. задачу E3 |
|
||||||
|
| `opencode.nix:339` | `systemd.user.services.opencode-web.Service` | `serviceConfig` рендерится в секцию `[serviceConfig]`, systemd молча игнорирует (`c73a698`); см. R2 |
|
||||||
|
| `vds.nix:73-91` | nftables без финального правила | Известный пробел, см. задачу A3 |
|
||||||
|
| `100.64.0.0` | Первый адрес CGNAT `/10` | Tailscale-адрес sapphira, см. R1.4 |
|
||||||
|
| `server.nix:61-63` | `z /mnt/services 0777` | World-writable точка монтирования; см. задачу B1 |
|
||||||
|
|
||||||
|
## Куда лезть по задаче
|
||||||
|
|
||||||
|
| Задача | Файл |
|
||||||
|
|---|---|
|
||||||
|
| Добавить хост | `configurations/default.nix` + `configurations/<host>.nix` + `configurations/{hardware,disko}/<host>.nix` |
|
||||||
|
| Добавить системный сервис | `modules/server/<name>.nix`, добавить в `modules/server/default.nix:imports` |
|
||||||
|
| Добавить home-пакет для пользователя | `home/<device_type>.nix` (через `lib.mkIf` или просто список) |
|
||||||
|
| Добавить опцию, читаемую несколькими модулями | `modules/options.nix` |
|
||||||
|
| Изменить mount/имя пользователя | `lib/xlib/dirs.nix`, `lib/xlib/device.nix` |
|
||||||
|
| Изменить домен / сертификат | `modules/server/coredns.nix` + `modules/server/nginx.nix` (или `vds/`) |
|
||||||
|
| Sops-секрет | положить в `secrets/<name>.<yaml\|json\|env\|ini>`; `users.nix:99` уже подключает `secrets/default.yaml`; dotenv/json-секреты — через `mkUserSecret` |
|
||||||
|
|
||||||
|
## Где НЕ лезть без ответа владельца
|
||||||
|
|
||||||
|
- `secrets/` (sops-encrypted, расшифровываются `/etc/ssh/id_ed25519` → циклический bootstrap).
|
||||||
|
- `let deploy` без проверки deploy-rs нод: `rydiwo` (ноутбук, может быть выключен).
|
||||||
|
- Любая правка, противоречащая «Подтверждённым инвариантам» выше (R1).
|
||||||
|
- `vetymae` / `lamet` / `therima` / `soptur` в `dirs.nix` — природа неясна (открытый вопрос 2.2/5.5).
|
||||||
|
- `192.168.1.20` в 30 местах — менять только при готовности править все места (открытый вопрос 6.6).
|
||||||
|
- Ядро Xray 26.7.x → 26.9.x — миграция провалена, не повторять без отдельной задачи.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# все outputs вычисляются
|
||||||
|
nix flake check
|
||||||
|
|
||||||
|
# правки применились на целевой хост
|
||||||
|
nix build .#nixosConfigurations.<host>.config.system.build.toplevel
|
||||||
|
|
||||||
|
# nixOnDroid
|
||||||
|
nix build .#nixOnDroidConfigurations.epral.config.system.build.toplevel
|
||||||
|
|
||||||
|
# внешний диск смонтирован (до рестарта сервисов на нём)
|
||||||
|
findmnt /home/oqyude/External
|
||||||
|
findmnt /mnt/services
|
||||||
|
|
||||||
|
# state of guard-зависимостей (когда будет todo B1)
|
||||||
|
systemctl show postgresql -p Requires -p After | tr ' ' '\n' | grep -E 'mnt-|home-oqyude'
|
||||||
|
|
||||||
|
# sops
|
||||||
|
sops --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конвенции проекта
|
||||||
|
|
||||||
|
- `xlib` (в `lib/xlib/`) — чистые данные: identity (`device`), capability flags,
|
||||||
|
директории, helper'ы. Передаётся в каждый модуль через `specialArgs`.
|
||||||
|
Конфиг не может переопределить `xlib` — единственная точка изменения это
|
||||||
|
`configurations/default.nix`.
|
||||||
|
- `device.type` ∈ { minimal, primary, secondary, server, vds, wsl, termux }.
|
||||||
|
`modules/defaultModule` импортирует `modules/<type>/` через
|
||||||
|
`lib.optional (!isDesktop && type != "minimal") (./. + "/${type}")`.
|
||||||
|
- `mkXlib` (`lib/xlib/default.nix:38-77`) — единственная точка сборки xlib.
|
||||||
|
- Опция живёт в `modules/options.nix`, если её **устанавливает** один модуль,
|
||||||
|
а **читает** другой. `host.reader.X.enable` живёт в `essentials/ssh.nix`,
|
||||||
|
потому что его объявляет и использует один модуль.
|
||||||
|
- `home/<type>.nix` = единственный источник «что есть на этом хосте» для
|
||||||
|
пользователя; добавление пакета в новый тип = правильный файл, а не
|
||||||
|
`home/default.nix`.
|
||||||
|
- `.sops.yaml`: один age-ключ (`*default`), `path_regex: secrets/[^/]+\.(yaml|json|env|ini)$`.
|
||||||
|
Покрывает только плоские файлы в `secrets/` (без подкаталогов). Дополнительные
|
||||||
|
секреты dotenv/json — через `mkUserSecret` (`users.nix:33-41`).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# BOUNDARIES — Рамки и границы
|
||||||
|
|
||||||
|
Что агенту **разрешено**, **запрещено** и в каких случаях **нужно остановиться**.
|
||||||
|
|
||||||
|
## Разрешено
|
||||||
|
|
||||||
|
| Действие | Примечание |
|
||||||
|
|---|---|
|
||||||
|
| Читать любые файлы в целевом репозитории | Включая `.git`, конфиги, историю |
|
||||||
|
| Создавать/изменять файлы в `.agent/` | Директория метаданных проекта (rules, decisions, tasks, context, requests, roadmap, archive) |
|
||||||
|
| Создавать `.temp/` в корне проекта | Для временных файлов агента. Всегда в `.gitignore` |
|
||||||
|
| Писать production-код | В фазе EXECUTION, по задачам из `manifest.json` |
|
||||||
|
| Рефакторить существующий код | Только если это часть задачи в `manifest.json` |
|
||||||
|
| Делать коммиты | По завершении задачи, перед созданием request |
|
||||||
|
| Создавать/дополнять `.gitignore` | Только для добавления `.temp/` |
|
||||||
|
| Устанавливать/обновлять зависимости | Через штатный пакетный менеджер проекта |
|
||||||
|
| Изменять конфигурационные файлы | Только если необходимо для сборки/тестов |
|
||||||
|
| Запускать сборку и тесты | Для верификации окружения и проверки request-ов |
|
||||||
|
| Читать документацию, issue, PRs | Для понимания контекста |
|
||||||
|
| Запрашивать уточнения у пользователя | Если не хватает информации для декомпозиции |
|
||||||
|
| Копировать исходники MetaAgent в `.agent/src/` целевого проекта | На фазе INIT, без перезаписи существующих файлов (если не указан `--update`) |
|
||||||
|
| Создавать/обновлять `AGENTS.md` в корне целевого проекта | Только если файла не существует |
|
||||||
|
| **Обязательно:** читать `.agent/rules/project-rules.md` перед каждой фазой | Правила пользователя имеют приоритет выше стандартных протоколов |
|
||||||
|
| Перемещать завершённые артефакты в `.agent/archive/` | На фазах METASTATE и HANDOFF |
|
||||||
|
| **Обязательно:** после выполнения задачи создавать request в `.agent/requests/active/` | Request — единица результата |
|
||||||
|
| Вызывать команды из `COMMANDS/` | По явной просьбе пользователя (`/adr`, `/red-team`, `/risk-register`, `/alt-arch`, `/invariant-tests`) |
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
|
||||||
|
| Действие | Почему |
|
||||||
|
|---|---|
|
||||||
|
| Удалять файлы | Если файл мешает — сообщить пользователю |
|
||||||
|
| Менять удалённые настройки CI/CD | Если CI сломан — сообщить пользователю |
|
||||||
|
| Модифицировать код, не связанный с задачей | Только то, что нужно в рамках задачи из `manifest.json` |
|
||||||
|
| Выполнять команды (`/adr`, `/red-team`, и т.д.) без явной просьбы | Команды — on-demand, не авто-фаза |
|
||||||
|
| Задавать пользователю вопросы про depth / scale / фичи | В v3.0 нет шкалы глубины. Просто работай |
|
||||||
|
|
||||||
|
## Когда остановиться
|
||||||
|
|
||||||
|
1. **Репозиторий не собирается** — сообщить пользователю с логом ошибки, не продолжать.
|
||||||
|
2. **Неясна цель** — запросить уточнение, не гадать.
|
||||||
|
3. **Обнаружены секреты/токены** — не копировать, сообщить пользователю.
|
||||||
|
4. **Цель выходит за рамки одной сессии** — разбить, запросить приоритет.
|
||||||
|
5. **Проект не использует известные технологии** — запросить инструкцию по сборке.
|
||||||
|
6. **Непонятно, какую команду вызвать** — спросить пользователя, не угадывать.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 3.0.0 — Упрощение модели
|
||||||
|
|
||||||
|
**Дата:** 2026-10-08
|
||||||
|
|
||||||
|
### Что изменилось
|
||||||
|
|
||||||
|
Принята модель «жизненный цикл + команды на вызов» вместо «жизненный цикл с уровнями глубины».
|
||||||
|
|
||||||
|
**Удалено:**
|
||||||
|
- Шкала глубины (depth 1-10) и все её варианты (Scaffold/Light/Standard/Deep/Maximum).
|
||||||
|
- Условные фичи в фазах: `adr`, `alternative_arch`, `red_team`, `risk_register`, `invariant_tests`.
|
||||||
|
- Интервью с пользователем на старте (5 вопросов про depth и фичи).
|
||||||
|
- `.agent/metaagent-request.md` — конфиг-файл, который сейчас не нужен.
|
||||||
|
- `TEMPLATES/metaagent-request.md`.
|
||||||
|
|
||||||
|
**Добавлено:**
|
||||||
|
- Директория `COMMANDS/` с пятью on-demand инструкциями: `adr.md`, `red-team.md`, `risk-register.md`, `alt-arch.md`, `invariant-tests.md`.
|
||||||
|
- `GUIDE.md` — заменяет `META_AGENT_GUIDE.md`, описание цикла + список команд.
|
||||||
|
- `CHANGELOG.md` — этот файл.
|
||||||
|
|
||||||
|
**Переименовано / перенумеровано:**
|
||||||
|
- `META_AGENT_GUIDE.md` → `GUIDE.md`.
|
||||||
|
- `PROTOCOLS/01_ANALYSIS.md` → `01_ANALYSE.md`.
|
||||||
|
- `PROTOCOLS/02_DESIGN.md` → `03_DESIGN.md`.
|
||||||
|
- `PROTOCOLS/03_DECOMPOSITION.md` → `04_DECOMPOSITION.md`.
|
||||||
|
- `PROTOCOLS/04_EXECUTION.md` → `05_EXECUTION.md`.
|
||||||
|
- `PROTOCOLS/05_HANDOFF.md` → `07_HANDOFF.md`.
|
||||||
|
- `PROTOCOLS/06_METASTATE.md` остался под тем же именем (теперь фаза 6).
|
||||||
|
|
||||||
|
**Удалены протоколы:**
|
||||||
|
- `PROTOCOLS/00_CONFIG.md` — конфигурация больше не нужна.
|
||||||
|
- `PROTOCOLS/00_MIGRATE.md` — миграция теперь документируется в этом CHANGELOG.
|
||||||
|
- `PROTOCOLS/04_ENVIRONMENT_SETUP.md` — поглощён фазой `00_INIT.md`.
|
||||||
|
- `PROTOCOLS/02b_REDTEAM.md` — теперь команда `COMMANDS/red-team.md`.
|
||||||
|
|
||||||
|
**Структура `.agent/checkpoints.json`** упрощена: убраны `config.depth`, `config.design.adr`, `config.red_team`, `config.risk_register`, `config.decomposition.invariant_tests`.
|
||||||
|
|
||||||
|
### Миграция с v2.1 → v3.0
|
||||||
|
|
||||||
|
Для проектов, созданных с MetaAgent v2.1:
|
||||||
|
|
||||||
|
1. **Удалить** из `.agent/checkpoints.json` секцию `config` целиком (она больше не читается).
|
||||||
|
2. **Удалить** `.agent/metaagent-request.md` (не используется).
|
||||||
|
3. **Удалить** `.agent/decisions/config.json`, если есть (аналог config для решений).
|
||||||
|
4. **Запустить** `install.sh --update` (или `install.ps1 -Update` / `install.bat --update`) — перезапишет исходники MetaAgent.
|
||||||
|
5. **Переименовать** пути в существующих артефактах: `layer-1/adr/` → `decisions/` (если остались с v1.x), `layer-2/analysis-report.md` → `context/analysis-report.md` и т.п. — это касается только проектов, оставшихся на v1.x.
|
||||||
|
6. **Записать** в `.agent/checkpoints.json` новое значение `metaagent_version: "3.0.0"`.
|
||||||
|
|
||||||
|
`request.json`, `manifest.json`, `decisions/index.json` остаются в том же формате, что в v2.1.
|
||||||
|
|
||||||
|
### Экономия
|
||||||
|
|
||||||
|
| | v2.1 | v3.0 |
|
||||||
|
|---|---|---|
|
||||||
|
| Markdown строк всего | ~3 820 | ~1 800 (целевой) |
|
||||||
|
| Протоколов | 10 | 8 |
|
||||||
|
| Уровней конфигурации | 5 (depth) | 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.1.0 — Project Loop + Work Loop + Requests
|
||||||
|
|
||||||
|
**Дата:** 2025-08 (предыдущая версия)
|
||||||
|
|
||||||
|
- Введён двухконтурный жизненный цикл: Project Loop (однократно) + Work Loop (циклически).
|
||||||
|
- Добавлены фазы: ROADMAP, METASTATE, RED_TEAM.
|
||||||
|
- Введены `requests/` как единица результата выполненной задачи.
|
||||||
|
- Введён `metaagent-request.md` с конфигом сессии (depth scale, фичи).
|
||||||
|
- Введена структура `.agent/` с семантическими директориями: `decisions/`, `tasks/`, `context/`, `rules/`, `requests/`, `roadmap/`, `archive/`.
|
||||||
|
- Шкала глубины 1-10 с условными фичами (adr, alternative_arch, red_team, risk_register, invariant_tests).
|
||||||
|
|
||||||
|
## 2.0.0 — Реструктуризация `.agent/`
|
||||||
|
|
||||||
|
- Переход от слоистой структуры `layer-0..3` к семантическим директориям.
|
||||||
|
- Полный MIGRATE-протокол для апгрейда с v1.x.
|
||||||
|
|
||||||
|
## 1.1.0 — Добавлены rules, archive
|
||||||
|
|
||||||
|
- `PROTOCOLS/01_ANALYSIS.md` обзавёлся правилами из `.agent/rules/`.
|
||||||
|
- Добавлена директория `archive/`.
|
||||||
|
|
||||||
|
## 1.0.0 — Первый релиз
|
||||||
|
|
||||||
|
- Односессионный pipeline: INIT → ANALYSE → DECOMP → SETUP → HANDOFF.
|
||||||
|
- Структура `layer-0..3`.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# ADR — Architecture Decision Record
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Зафиксировать архитектурное решение в `.agent/decisions/NNN-slug.md` так, чтобы будущий агент (или человек) мог понять: что решили, почему, какие альтернативы рассматривали, какие последствия.
|
||||||
|
|
||||||
|
ADR создаются по явной команде пользователя: «запиши это как решение», «/adr», «сделай ADR для текущего подхода».
|
||||||
|
|
||||||
|
## Когда вызывать
|
||||||
|
|
||||||
|
- Принято неочевидное архитектурное решение (выбор БД, паттерна, библиотеки, структуры модулей).
|
||||||
|
- Решение может измениться в будущем — стоит зафиксировать контекст.
|
||||||
|
- Есть trade-off, который нужно объяснить следующему агенту.
|
||||||
|
|
||||||
|
Не вызывать для очевидных вещей: «используем pytest», «классы называем в PascalCase».
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- Контекст решения: что обсуждалось, какие варианты сравнивались, что выбрали.
|
||||||
|
- `.agent/decisions/index.json` — текущий список ADR (для нумерации).
|
||||||
|
- `.agent/context/project-state.md` — текущее состояние проекта.
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 1. Определить номер
|
||||||
|
|
||||||
|
Прочитать `.agent/decisions/index.json`. Следующий номер = max существующих + 1. Если файла нет — создать, начать с 001.
|
||||||
|
|
||||||
|
### 2. Slug
|
||||||
|
|
||||||
|
Короткое имя в kebab-case, отражающее суть: `use-sqlite-for-mvp`, `auth-via-jwt-cookies`, `modular-monolith`.
|
||||||
|
|
||||||
|
### 3. Записать ADR
|
||||||
|
|
||||||
|
Создать `.agent/decisions/{NNN}-{slug}.md` по шаблону `TEMPLATES/adr-NNNN.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# {NNN}. {Заголовок}
|
||||||
|
|
||||||
|
**Дата:** {YYYY-MM-DD}
|
||||||
|
**Статус:** Accepted | Superseded by {NNN} | Deprecated
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
{Что за проблема. Какие ограничения. Что нужно было решить.}
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
{Что выбрали. Коротко и конкретно.}
|
||||||
|
|
||||||
|
## Альтернативы, которые рассмотрели
|
||||||
|
|
||||||
|
### {Альтернатива 1}
|
||||||
|
{Описание. Почему не выбрали.}
|
||||||
|
|
||||||
|
### {Альтернатива 2}
|
||||||
|
{Описание. Почему не выбрали.}
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
### Положительные
|
||||||
|
- {что становится лучше}
|
||||||
|
|
||||||
|
### Отрицательные
|
||||||
|
- {что становится хуже или сложнее}
|
||||||
|
|
||||||
|
### Инварианты
|
||||||
|
- {что не должно сломаться, чтобы решение оставалось валидным}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Обновить index.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "3.0.0",
|
||||||
|
"decisions": [
|
||||||
|
{ "id": "001", "title": "Использовать SQLite для MVP", "file": "001-use-sqlite-for-mvp.md", "status": "Accepted" }
|
||||||
|
],
|
||||||
|
"last_updated": "{timestamp}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Если есть supersession
|
||||||
|
|
||||||
|
Если новый ADR отменяет старый — в старом ADR поставить `Статус: Superseded by {NNN}` и добавить ссылку. В новом — в контексте упомянуть, что отменяет.
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/decisions/{NNN}-{slug}.md`
|
||||||
|
- Обновлённый `.agent/decisions/index.json`
|
||||||
|
|
||||||
|
## Связанные команды
|
||||||
|
|
||||||
|
- **/invariant-tests** — после ADR можно зафиксировать инварианты как задачи в manifest.
|
||||||
|
- **/alt-arch** — если хочется явно зафиксировать альтернативу до решения.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Alternative Architecture
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Описать альтернативный вариант архитектуры / подхода, чтобы сравнить с текущим и принять осознанное решение. Не «сделать вместо», а «сравнить и выбрать».
|
||||||
|
|
||||||
|
## Когда вызывать
|
||||||
|
|
||||||
|
- Текущий дизайн кажется спорным, нужна трезвая оценка альтернативы.
|
||||||
|
- Хочется зафиксировать «почему не сделали иначе» — потом пригодится при росте.
|
||||||
|
- Перед крупным решением (выбор БД, монолит-vs-микросервисы, sync-vs-async).
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- Текущий дизайн / план (`.agent/context/design-report.md` или текущее состояние).
|
||||||
|
- Ограничения проекта (сроки, стек, бюджет).
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 1. Определить, что сравниваем
|
||||||
|
|
||||||
|
Один конкретный вопрос: «SQLite vs PostgreSQL», «монолит vs микросервисы», «REST vs GraphQL», «sync-обработка vs очередь».
|
||||||
|
|
||||||
|
### 2. Сформулировать альтернативу
|
||||||
|
|
||||||
|
Краткое описание: что предлагается вместо текущего подхода. Без длинного дизайна — на уровне «как это работает и чем отличается».
|
||||||
|
|
||||||
|
### 3. Сравнить
|
||||||
|
|
||||||
|
| Аспект | Текущий | Альтернатива |
|
||||||
|
|---|---|---|
|
||||||
|
| Сложность реализации | | |
|
||||||
|
| Время до MVP | | |
|
||||||
|
| Производительность | | |
|
||||||
|
| Масштабирование | | |
|
||||||
|
| Поддерживаемость | | |
|
||||||
|
| Стоимость изменений | | |
|
||||||
|
| Риски | | |
|
||||||
|
|
||||||
|
### 4. Записать
|
||||||
|
|
||||||
|
Создать `.agent/context/alt-architecture.md` (если файла нет) или дополнить. Структура:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Alternative Architecture — {что сравниваем}
|
||||||
|
|
||||||
|
**Дата:** {YYYY-MM-DD}
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
{Почему рассматриваем альтернативу. Что не устраивает в текущем.}
|
||||||
|
|
||||||
|
## Альтернатива
|
||||||
|
|
||||||
|
{Краткое описание. Архитектура, ключевые компоненты, поток данных.}
|
||||||
|
|
||||||
|
## Сравнение
|
||||||
|
|
||||||
|
{Таблица из шага 3.}
|
||||||
|
|
||||||
|
## Когда альтернатива выигрывает
|
||||||
|
|
||||||
|
{В каких условиях стоит переключиться. Триггеры для миграции.}
|
||||||
|
|
||||||
|
## Когда остаёмся на текущем
|
||||||
|
|
||||||
|
{Что в текущем работает достаточно хорошо, чтобы не менять.}
|
||||||
|
|
||||||
|
## Рекомендация
|
||||||
|
|
||||||
|
{Остаёмся или мигрируем. Почему.}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Связать с ADR
|
||||||
|
|
||||||
|
Если после сравнения принимается решение — использовать **/adr** для фиксации. Альтернативный файл остаётся как исторический артефакт.
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/context/alt-architecture.md`
|
||||||
|
|
||||||
|
## Связанные команды
|
||||||
|
|
||||||
|
- **/adr** — зафиксировать итоговое решение.
|
||||||
|
- **/risk-register** — если альтернатива снимает/добавляет риски.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Invariant Tests
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Превратить инварианты из ADR в задачи-тесты в `.agent/tasks/manifest.json`. Инвариант — это «что не должно сломаться, чтобы ADR оставался валидным». Без явного теста это просто слова.
|
||||||
|
|
||||||
|
## Когда вызывать
|
||||||
|
|
||||||
|
- После создания ADR, в секции «Инварианты» которого перечислены условия валидности решения.
|
||||||
|
- Когда хочется, чтобы архитектурные решения были защищены регрессионными тестами.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/decisions/*.md` — ADR с секцией «Инварианты».
|
||||||
|
- `.agent/tasks/manifest.json` — текущий манифест (для нумерации задач).
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 1. Найти ADR с инвариантами
|
||||||
|
|
||||||
|
Прочитать все `.agent/decisions/*.md`, найти секции «Инварианты».
|
||||||
|
|
||||||
|
### 2. Для каждого инварианта — задача
|
||||||
|
|
||||||
|
Каждый инвариант = одна задача-тест. Формат:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "T-INV-001",
|
||||||
|
"title": "Invariant: auth-сессия не переживает рестарт сервиса",
|
||||||
|
"type": "test",
|
||||||
|
"origin": "invariant:001",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Тест рестартит auth-сервис и проверяет, что все сессии инвалидированы",
|
||||||
|
"Тест проверяет, что refresh-токен не работает после рестарта"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"tests/auth/test_invariants.py"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Добавить в manifest
|
||||||
|
|
||||||
|
Записать задачи в `.agent/tasks/manifest.json` с `status: "pending"`. Связать `depends_on` с задачами, которые реализуют компонент (если ещё не выполнены).
|
||||||
|
|
||||||
|
### 4. Связать с ADR
|
||||||
|
|
||||||
|
В самом ADR добавить (опционально) ссылку на задачу-инвариант:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Инварианты
|
||||||
|
- {{ ... }}
|
||||||
|
|
||||||
|
### Покрытие тестами
|
||||||
|
- T-INV-001: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- Новые задачи в `.agent/tasks/manifest.json` с `origin: "invariant:{adr_id}"`
|
||||||
|
- (опционально) обновлённый ADR со ссылкой на задачи
|
||||||
|
|
||||||
|
## Связанные команды
|
||||||
|
|
||||||
|
- **/adr** — источник инвариантов.
|
||||||
|
- **/risk-register** — некоторые инварианты рождаются из рисков.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Red Team Review
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Попытаться сломать текущий дизайн / архитектуру / план. Зафиксировать найденные уязвимости в `.agent/context/red-team-report.md`, чтобы разработчик мог их закрыть до реализации.
|
||||||
|
|
||||||
|
Red Team — это adversarial-проход по дизайну. Не «улучшить», а «найти, что не так».
|
||||||
|
|
||||||
|
## Когда вызывать
|
||||||
|
|
||||||
|
- После фазы DESIGN, до декомпозиции задач.
|
||||||
|
- Когда дизайн кажется слишком гладким.
|
||||||
|
- Перед крупным рефакторингом.
|
||||||
|
- Когда непонятно, какие риски у текущего подхода.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/context/design-report.md` (если есть).
|
||||||
|
- `.agent/decisions/*.md` — связанные ADR.
|
||||||
|
- `.agent/context/project-state.md` — текущее состояние.
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 1. Прочитать целевой дизайн
|
||||||
|
|
||||||
|
Понять, что именно ревьюится: вся архитектура, конкретный модуль, конкретное решение.
|
||||||
|
|
||||||
|
### 2. Провести атаки по категориям
|
||||||
|
|
||||||
|
#### 2.1. Нагрузка и масштабирование
|
||||||
|
- Что будет при 10x / 100x объёма?
|
||||||
|
- Где узкое место?
|
||||||
|
- Что сломается первым?
|
||||||
|
|
||||||
|
#### 2.2. Отказы и доступность
|
||||||
|
- Что если упадёт БД / кэш / внешний сервис?
|
||||||
|
- Есть ли SPOF (single point of failure)?
|
||||||
|
- Как восстанавливаемся?
|
||||||
|
|
||||||
|
#### 2.3. Безопасность
|
||||||
|
- Где хранятся секреты?
|
||||||
|
- Какие поверхности атаки?
|
||||||
|
- Что с аутентификацией / авторизацией?
|
||||||
|
- Injection, SSRF, XSS — что релевантно?
|
||||||
|
|
||||||
|
#### 2.4. Корректность
|
||||||
|
- Где гонки (race conditions)?
|
||||||
|
- Что с консистентностью данных?
|
||||||
|
- Какие edge cases не покрыты?
|
||||||
|
|
||||||
|
#### 2.5. Поддерживаемость
|
||||||
|
- Что будет сложно менять через год?
|
||||||
|
- Где связность, которую придётся разрывать?
|
||||||
|
- Какие зависимости могут устареть?
|
||||||
|
|
||||||
|
#### 2.6. Миграция и совместимость
|
||||||
|
- Если меняем API — как старые клиенты переживут?
|
||||||
|
- Если меняем схему БД — что со старыми данными?
|
||||||
|
- Если выкатываем поэтапно — какой план?
|
||||||
|
|
||||||
|
### 3. Записать отчёт
|
||||||
|
|
||||||
|
Создать `.agent/context/red-team-report.md` (если файла нет) или дополнить:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Red Team Review — {что ревьюим}
|
||||||
|
|
||||||
|
**Дата:** {YYYY-MM-DD}
|
||||||
|
**Цель:** {что именно атакуем}
|
||||||
|
|
||||||
|
## Критические находки
|
||||||
|
|
||||||
|
### R1. {Краткое название}
|
||||||
|
- **Категория:** безопасность / нагрузка / корректность / ...
|
||||||
|
- **Сценарий:** {как воспроизвести}
|
||||||
|
- **Воздействие:** {что произойдёт}
|
||||||
|
- **Рекомендация:** {что сделать}
|
||||||
|
|
||||||
|
## Существенные находки
|
||||||
|
|
||||||
|
### R2. ...
|
||||||
|
|
||||||
|
## Минорные находки
|
||||||
|
|
||||||
|
### R3. ...
|
||||||
|
|
||||||
|
## Что выдержало атаку
|
||||||
|
|
||||||
|
- {Что оказалось надёжным — это тоже полезно знать.}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Связать с задачами
|
||||||
|
|
||||||
|
Если находка превращается в задачу — добавить в `.agent/tasks/manifest.json` (фаза DECOMPOSITION) с `origin: "red-team:{номер_находки}"`.
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/context/red-team-report.md`
|
||||||
|
- (опционально) новые задачи в manifest
|
||||||
|
|
||||||
|
## Связанные команды
|
||||||
|
|
||||||
|
- **/adr** — если Red Team выявил, что нужно зафиксировать решение иначе.
|
||||||
|
- **/risk-register** — для систематизации рисков.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Risk Register
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Явный реестр допущений и рисков проекта в `.agent/context/risk-register.md`. Чтобы не держать в голове «ну мы же понимаем, что X может сломаться» — а записать, оценить и (если надо) превратить в задачи.
|
||||||
|
|
||||||
|
## Когда вызывать
|
||||||
|
|
||||||
|
- В начале проекта — зафиксировать стартовые допущения.
|
||||||
|
- При появлении нового риска (новый внешний сервис, новая зависимость, новое требование).
|
||||||
|
- При обзоре дизайна (после DESIGN или Red Team).
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/context/design-report.md` (если есть).
|
||||||
|
- `.agent/context/analysis-report.md` — что уже знаем о проекте.
|
||||||
|
- `.agent/decisions/*.md` — принятые решения (могут быть источниками рисков).
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 1. Собрать риски
|
||||||
|
|
||||||
|
Источники:
|
||||||
|
- Допущения, на которых держится дизайн («считаем, что PostgreSQL выдержит 1k qps»).
|
||||||
|
- Внешние зависимости без SLA.
|
||||||
|
- Технологии, которые команда не знает.
|
||||||
|
- Сроки, которые давят.
|
||||||
|
- Решения, которые сложно откатить.
|
||||||
|
|
||||||
|
### 2. Оценить каждый риск
|
||||||
|
|
||||||
|
По двум осям:
|
||||||
|
- **Вероятность** (1-низкая, 2-средняя, 3-высокая).
|
||||||
|
- **Воздействие** (1-небольшое, 2-серьёзное, 3-критическое).
|
||||||
|
|
||||||
|
`score = вероятность × воздействие` (1-9).
|
||||||
|
|
||||||
|
### 3. Записать
|
||||||
|
|
||||||
|
Создать или дополнить `.agent/context/risk-register.md` по шаблону `TEMPLATES/risk-register.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Risk Register
|
||||||
|
|
||||||
|
**Дата:** {YYYY-MM-DD}
|
||||||
|
|
||||||
|
## Высокий риск (score 6-9)
|
||||||
|
|
||||||
|
### R-001. {Краткое название}
|
||||||
|
- **Категория:** технический / продуктовый / организационный
|
||||||
|
- **Описание:** {что может пойти не так}
|
||||||
|
- **Воздействие:** {что будет если случится}
|
||||||
|
- **Вероятность:** 3 / 2 / 1
|
||||||
|
- **Счёт:** 9 / 6 / 4
|
||||||
|
- **Митигация:** {что делаем чтобы уменьшить}
|
||||||
|
- **Владелец:** {кто отвечает}
|
||||||
|
- **Статус:** open / mitigated / accepted / closed
|
||||||
|
|
||||||
|
## Средний риск (score 3-4)
|
||||||
|
...
|
||||||
|
|
||||||
|
## Низкий риск (score 1-2)
|
||||||
|
...
|
||||||
|
|
||||||
|
## Закрытые риски
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Связать с задачами
|
||||||
|
|
||||||
|
Если риск требует действия — добавить задачу в `.agent/tasks/manifest.json` с `origin: "risk:R-001"`.
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/context/risk-register.md`
|
||||||
|
|
||||||
|
## Связанные команды
|
||||||
|
|
||||||
|
- **/red-team** — источник технических рисков.
|
||||||
|
- **/adr** — некоторые риски закрываются через принятое решение.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# MetaAgent GUIDE v3.0
|
||||||
|
|
||||||
|
MetaAgent — набор инструкций для AI-агента. Задача: превратить хаотичное общение с агентом в структурированный процесс, в котором состояние проекта переживает любую сессию.
|
||||||
|
|
||||||
|
## Два слоя
|
||||||
|
|
||||||
|
- **Цикл** (всегда, по необходимости) — последовательность фаз, которую агент проходит при работе с проектом.
|
||||||
|
- **Команды** (по запросу пользователя) — on-demand инструкции, которые не привязаны к фазе.
|
||||||
|
|
||||||
|
Состояние проекта живёт в `.agent/` целевого репозитория. Следующий агент читает `.agent/` и не лезет в исходники.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Цикл
|
||||||
|
|
||||||
|
```
|
||||||
|
.agent/checkpoints.json
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ PROJECT LOOP (разово) │
|
||||||
|
│ │
|
||||||
|
│ INIT → ANALYSE → ROADMAP → │
|
||||||
|
│ → [DESIGN] → DECOMPOSITION │
|
||||||
|
│ │
|
||||||
|
│ Выход: .agent/tasks/manifest.json │
|
||||||
|
└──────────────────┬──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ WORK LOOP (циклически) │
|
||||||
|
│ │
|
||||||
|
│ EXECUTION → (request) → │
|
||||||
|
│ → METASTATE (по команде) │
|
||||||
|
│ │
|
||||||
|
│ Беру задачу → делаю → request → │
|
||||||
|
│ накопилось → METASTATE │
|
||||||
|
└──────────────────┬──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
HANDOFF (завершение)
|
||||||
|
```
|
||||||
|
|
||||||
|
Фазы выполняются **строго последовательно** внутри PROJECT LOOP. WORK LOOP повторяется многократно.
|
||||||
|
|
||||||
|
### Ветвление
|
||||||
|
|
||||||
|
| Тип проекта | Цикл |
|
||||||
|
|---|---|
|
||||||
|
| **existing** | INIT → ANALYSE → ROADMAP → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF |
|
||||||
|
| **greenfield / scaffold** | + фаза DESIGN между ROADMAP и DECOMPOSITION |
|
||||||
|
|
||||||
|
Тип проекта определяется автоматически в фазе ANALYSE. Никакого интервью с пользователем, никакой шкалы глубины.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фазы
|
||||||
|
|
||||||
|
| # | Фаза | Протокол | Что делает |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | INIT | `PROTOCOLS/00_INIT.md` | Создаёт `.agent/`, ставит исходники, инициализирует checkpoints |
|
||||||
|
| 1 | ANALYSE | `PROTOCOLS/01_ANALYSE.md` | Сканирует проект, создаёт `analysis-report.md` + начальный `project-state.md` |
|
||||||
|
| 2 | ROADMAP | `PROTOCOLS/02_ROADMAP.md` | Собирает источники задач (FUTURE, ADR, user-запросы) → `roadmap/sources.md` |
|
||||||
|
| 3 | DESIGN | `PROTOCOLS/03_DESIGN.md` | Только greenfield. Архитектура, модули, API, модели |
|
||||||
|
| 4 | DECOMPOSITION | `PROTOCOLS/04_DECOMPOSITION.md` | Разбивает цель на атомарные задачи → `tasks/manifest.json` |
|
||||||
|
| 5 | EXECUTION | `PROTOCOLS/05_EXECUTION.md` | Цикл: берёт задачу → код → тесты → коммит → request |
|
||||||
|
| 6 | METASTATE | `PROTOCOLS/06_METASTATE.md` | По команде. Ревью requests, обновление project-state, handoff-summary |
|
||||||
|
| 7 | HANDOFF | `PROTOCOLS/07_HANDOFF.md` | Валидация `.agent/`, финализация checkpoints, session-summary |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
Эти инструкции выполняются **по явной просьбе пользователя** в любой момент сессии. Они не привязаны к фазе.
|
||||||
|
|
||||||
|
| Команда | Файл | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| «запиши ADR» / «/adr» | `COMMANDS/adr.md` | Создаёт `.agent/decisions/NNN-slug.md` |
|
||||||
|
| «red team» / «/red-team» | `COMMANDS/red-team.md` | Создаёт `.agent/context/red-team-report.md` — попытка сломать дизайн |
|
||||||
|
| «risk register» / «/risk-register» | `COMMANDS/risk-register.md` | Создаёт `.agent/context/risk-register.md` |
|
||||||
|
| «альтернативная архитектура» / «/alt-arch» | `COMMANDS/alt-arch.md` | Описывает альтернативу текущему дизайну |
|
||||||
|
| «invariant-тесты» / «/invariant-tests» | `COMMANDS/invariant-tests.md` | Создаёт задачи-инварианты для ADR |
|
||||||
|
|
||||||
|
### Когда вызывать
|
||||||
|
|
||||||
|
- **ADR** — после архитектурного решения, которое нужно зафиксировать. Типично во время DESIGN или при появлении неочевидного выбора в EXECUTION.
|
||||||
|
- **Red Team** — после готового дизайна, чтобы найти слабые места до реализации.
|
||||||
|
- **Risk Register** — в начале проекта или при появлении новых допущений.
|
||||||
|
- **Alt Arch** — если сомневаетесь в выбранном подходе, хотите сравнить варианты.
|
||||||
|
- **Invariant Tests** — после ADR, чтобы зафиксировать «что не должно сломаться».
|
||||||
|
|
||||||
|
Команды **не обязательны**. Если не вызваны — не выполняются. Состояние проекта от них не зависит.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура `.agent/`
|
||||||
|
|
||||||
|
```
|
||||||
|
.agent/
|
||||||
|
checkpoints.json # состояние сессии (ядро)
|
||||||
|
session-summary.md # краткая сводка сессии
|
||||||
|
handoff-summary.md # сводка для следующего агента (создаётся METASTATE)
|
||||||
|
|
||||||
|
src/ # исходники MetaAgent (всегда)
|
||||||
|
GUIDE.md
|
||||||
|
BOUNDARIES.md
|
||||||
|
CHANGELOG.md
|
||||||
|
PROTOCOLS/
|
||||||
|
COMMANDS/
|
||||||
|
TEMPLATES/
|
||||||
|
VERSION
|
||||||
|
install.sh / install.ps1
|
||||||
|
|
||||||
|
rules/
|
||||||
|
project-rules.md # ваши правила — читать перед каждой фазой
|
||||||
|
|
||||||
|
roadmap/ # источники задач
|
||||||
|
sources.md
|
||||||
|
archive/
|
||||||
|
|
||||||
|
decisions/ # ADR
|
||||||
|
index.json
|
||||||
|
001-*.md
|
||||||
|
|
||||||
|
tasks/ # задачи
|
||||||
|
manifest.json + manifest.md
|
||||||
|
backlog/
|
||||||
|
|
||||||
|
requests/ # результаты выполненных задач
|
||||||
|
active/ # ready_for_review
|
||||||
|
archive/ # approved / rejected
|
||||||
|
|
||||||
|
context/
|
||||||
|
analysis-report.md
|
||||||
|
project-state.md # обновляется в METASTATE
|
||||||
|
design-report.md # только greenfield
|
||||||
|
red-team-report.md # если вызывали /red-team
|
||||||
|
risk-register.md # если вызывали /risk-register
|
||||||
|
baseline-test-report.log
|
||||||
|
|
||||||
|
archive/
|
||||||
|
index.json
|
||||||
|
tasks/
|
||||||
|
decisions/
|
||||||
|
requests/
|
||||||
|
checkpoints/
|
||||||
|
```
|
||||||
|
|
||||||
|
`.temp/` в корне проекта — для временных файлов агента. Всегда в `.gitignore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoints
|
||||||
|
|
||||||
|
`checkpoints.json` обновляется после каждой фазы:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metaagent_version": "3.0.0",
|
||||||
|
"session_id": "<uuid>",
|
||||||
|
"target_repo": "<path>",
|
||||||
|
"goal": "<цель>",
|
||||||
|
"project_type": "existing | greenfield | scaffold",
|
||||||
|
"phases": {
|
||||||
|
"init": "completed",
|
||||||
|
"analyse": "completed",
|
||||||
|
"roadmap": "completed",
|
||||||
|
"design": "skipped",
|
||||||
|
"decomposition": "completed",
|
||||||
|
"execution": "in_progress",
|
||||||
|
"metastate": "pending",
|
||||||
|
"handoff": "pending"
|
||||||
|
},
|
||||||
|
"tasks": [
|
||||||
|
{ "id": "T1", "title": "...", "status": "in_progress", "origin": "user:direct" }
|
||||||
|
],
|
||||||
|
"last_updated": "<timestamp>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Секции `config` больше нет. Параметры, которые раньше были в `config` (depth, adr, red_team и т.п.), теперь либо не существуют, либо живут в отдельных командах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
### Цикл vs команды
|
||||||
|
|
||||||
|
Цикл — это «что агент делает по умолчанию». Команды — «что агент делает по явной просьбе». Не путать: ADR не запускается автоматически в DESIGN, а только когда пользователь скажет «запиши это как решение».
|
||||||
|
|
||||||
|
### `.agent/` как слепок проекта
|
||||||
|
|
||||||
|
После METASTATE `.agent/` содержит всю картину. Следующий агент читает только `.agent/`, не исходники.
|
||||||
|
|
||||||
|
### Request — единица результата
|
||||||
|
|
||||||
|
Каждая выполненная задача в EXECUTION завершается созданием `request` (`.agent/requests/active/req-{id}.json`). Request содержит суть изменений, коммиты, верификацию, закрытые acceptance criteria. Ревью request-ов происходит в METASTATE.
|
||||||
|
|
||||||
|
### Правила выше протоколов
|
||||||
|
|
||||||
|
Перед каждой фазой читать `.agent/rules/project-rules.md`. Если правило пользователя противоречит протоколу — следовать правилу.
|
||||||
|
|
||||||
|
### Контекст бесконечно не растёт
|
||||||
|
|
||||||
|
Завершённые задачи архивируются в `.agent/archive/tasks/`, request-ы — в `.agent/requests/archive/`. Текущий manifest остаётся lean.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Протокол 00: Инициализация (INIT)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Подготовить `.agent/` в целевом репозитории: установить исходники MetaAgent, создать структуру директорий, инициализировать `checkpoints.json`, создать/обновить `AGENTS.md`.
|
||||||
|
|
||||||
|
INIT выполняется **один раз** в начале работы с проектом. Если `.agent/` уже существует и инициализирован — пропускается.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- Целевой репозиторий (путь или текущая директория)
|
||||||
|
- `VERSION` — текущая версия MetaAgent
|
||||||
|
- Опционально: существующий `.agent/` (если обновление)
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 0.1. Определить целевой репозиторий
|
||||||
|
|
||||||
|
Если не указан явно — текущая рабочая директория. Если указан как URL — клонировать во временную директорию, дальше работать с копией.
|
||||||
|
|
||||||
|
### 0.2. Проверить существующий `.agent/`
|
||||||
|
|
||||||
|
Если `.agent/` существует:
|
||||||
|
|
||||||
|
- Прочитать `.agent/checkpoints.json` → `metaagent_version`
|
||||||
|
- Если `metaagent_version == VERSION` → INIT уже выполнен, выйти
|
||||||
|
- Если версия старше → запустить `install.sh --update` (Unix) или `install.ps1 -Update` (Windows) для переустановки исходников, затем выйти
|
||||||
|
- Если `.agent/` есть, но `checkpoints.json` отсутствует → продолжить INIT (создать checkpoints)
|
||||||
|
|
||||||
|
Если `.agent/` не существует → продолжить INIT.
|
||||||
|
|
||||||
|
### 0.3. Создать структуру `.agent/`
|
||||||
|
|
||||||
|
Создать директории:
|
||||||
|
|
||||||
|
```
|
||||||
|
.agent/
|
||||||
|
src/ # исходники MetaAgent (копируются из METAAGENT_SRC)
|
||||||
|
rules/
|
||||||
|
decisions/
|
||||||
|
tasks/
|
||||||
|
backlog/
|
||||||
|
context/
|
||||||
|
requests/
|
||||||
|
active/
|
||||||
|
archive/
|
||||||
|
roadmap/
|
||||||
|
archive/
|
||||||
|
archive/
|
||||||
|
tasks/
|
||||||
|
decisions/
|
||||||
|
requests/
|
||||||
|
checkpoints/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 0.4. Создать `.temp/` в корне проекта
|
||||||
|
|
||||||
|
Если не существует — создать `.temp/` в корне целевого репозитория. Добавить в `.gitignore` (если его нет — создать с одной строкой `.temp/`).
|
||||||
|
|
||||||
|
### 0.5. Скопировать исходники MetaAgent
|
||||||
|
|
||||||
|
Скопировать в `.agent/src/`:
|
||||||
|
|
||||||
|
- `GUIDE.md`
|
||||||
|
- `BOUNDARIES.md`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- `VERSION`
|
||||||
|
- `PROTOCOLS/`
|
||||||
|
- `COMMANDS/`
|
||||||
|
- `TEMPLATES/`
|
||||||
|
- `install.sh`, `install.ps1`
|
||||||
|
|
||||||
|
Существующие файлы в `.agent/src/` не перезаписывать (только с явным `--update`).
|
||||||
|
|
||||||
|
### 0.6. Создать `.agent/rules/project-rules.md`
|
||||||
|
|
||||||
|
Если файла нет — создать по шаблону `TEMPLATES/project-rules.md`.
|
||||||
|
|
||||||
|
### 0.7. Создать/обновить `AGENTS.md` в корне
|
||||||
|
|
||||||
|
Если `AGENTS.md` в корне проекта отсутствует — создать по `AGENTS.template.md` с подставленной версией.
|
||||||
|
|
||||||
|
Если существует и не относится к MetaAgent — не трогать (попросить пользователя переименовать или подтвердить перезапись).
|
||||||
|
|
||||||
|
### 0.8. Инициализировать `checkpoints.json`
|
||||||
|
|
||||||
|
Создать `.agent/checkpoints.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metaagent_version": "3.0.0",
|
||||||
|
"session_id": "<uuid>",
|
||||||
|
"target_repo": "<путь>",
|
||||||
|
"goal": null,
|
||||||
|
"project_type": null,
|
||||||
|
"phases": {
|
||||||
|
"init": "completed",
|
||||||
|
"analyse": "pending",
|
||||||
|
"roadmap": "pending",
|
||||||
|
"design": "pending",
|
||||||
|
"decomposition": "pending",
|
||||||
|
"execution": "pending",
|
||||||
|
"metastate": "pending",
|
||||||
|
"handoff": "pending"
|
||||||
|
},
|
||||||
|
"tasks": [],
|
||||||
|
"last_updated": "<timestamp>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Поля `goal` и `project_type` остаются `null` до фазы ANALYSE (goal может быть задан пользователем заранее — тогда заполнить сразу).
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/` с полной структурой
|
||||||
|
- `.agent/src/` с актуальными исходниками MetaAgent
|
||||||
|
- `.agent/rules/project-rules.md`
|
||||||
|
- `.agent/checkpoints.json` со `session_id` и `phases.init = "completed"`
|
||||||
|
- `AGENTS.md` в корне проекта
|
||||||
|
- `.temp/` в корне + `.gitignore` обновлён
|
||||||
|
|
||||||
|
## Критерии завершения
|
||||||
|
|
||||||
|
- [ ] `.agent/` содержит все обязательные директории
|
||||||
|
- [ ] `.agent/src/` содержит GUIDE.md, PROTOCOLS/, COMMANDS/, TEMPLATES/, VERSION
|
||||||
|
- [ ] `.agent/checkpoints.json` валиден (JSON parse)
|
||||||
|
- [ ] `AGENTS.md` присутствует в корне
|
||||||
|
- [ ] `.temp/` существует и в `.gitignore`
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Протокол 01: Анализ репозитория (ANALYSE)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Составить полную картину целевого репозитория: тип проекта, стек, архитектура, конвенции, состояние тестов. Создать начальный слепок проекта.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- Целевой репозиторий
|
||||||
|
- `.agent/checkpoints.json` (фаза analyse: pending)
|
||||||
|
- `.agent/rules/project-rules.md` — прочитать первым
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 1.1. Прочитать правила проекта
|
||||||
|
|
||||||
|
Прежде чем что-либо делать — прочитать `.agent/rules/project-rules.md`. Если есть правила, применить их к фазе.
|
||||||
|
|
||||||
|
### 1.2. Определить тип проекта
|
||||||
|
|
||||||
|
Просканировать корень репозитория:
|
||||||
|
|
||||||
|
- **`existing`** — есть исходный код, тесты, система сборки (`.py`, `.js`, `.ts`, `.rs`, `.go` и т.д. помимо конфигов и README).
|
||||||
|
- **`greenfield`** — пусто или только README/LICENSE/.gitignore.
|
||||||
|
- **`scaffold`** — есть базовая структура (`pyproject.toml`/`package.json`), но нет значимого кода.
|
||||||
|
|
||||||
|
Записать тип в `checkpoints.json → project_type`.
|
||||||
|
|
||||||
|
### 1.3. Сканировать проект
|
||||||
|
|
||||||
|
Для `existing` / `scaffold` собрать:
|
||||||
|
|
||||||
|
- **README** — описание, инструкции по сборке/тестам.
|
||||||
|
- **Лицензия** — какой LICENSE.
|
||||||
|
- **CI/CD** — `.github/workflows/`, `.gitlab-ci.yml`, `Jenkinsfile`, `Makefile`.
|
||||||
|
- **Стек** — язык, фреймворк, БД, тестовый раннер, пакетный менеджер, линтер.
|
||||||
|
- **Структура** — `tree -L 3` (не более 3 уровней).
|
||||||
|
- **Архитектурный паттерн** — MVC, модульный монолит, микросервисы, слоистая.
|
||||||
|
- **Ключевые модули/пакеты** — список с краткой ответственностью.
|
||||||
|
- **Конвенции** — стиль, именование, обработка ошибок, логирование.
|
||||||
|
- **Тесты** — где лежат, как запускаются, текущее состояние (запустить).
|
||||||
|
- **Сборка** — выполняется ли проект.
|
||||||
|
|
||||||
|
Для `greenfield` — извлечь требования из README:
|
||||||
|
|
||||||
|
- Функциональные требования (user stories, сценарии).
|
||||||
|
- Нефункциональные (стек, производительность, безопасность).
|
||||||
|
- Бизнес-контекст (зачем, для кого).
|
||||||
|
- Сомнительные / неясные требования (вопросы пользователю).
|
||||||
|
|
||||||
|
### 1.4. Создать analysis-report
|
||||||
|
|
||||||
|
Записать `.agent/context/analysis-report.md` по шаблону `TEMPLATES/analysis-report.md`. Заполнить соответствующие секции.
|
||||||
|
|
||||||
|
### 1.5. Создать начальный project-state
|
||||||
|
|
||||||
|
Создать `.agent/context/project-state.md` по шаблону `TEMPLATES/project-state.md`. Это **начальный** слепок. В дальнейшем обновляется в фазе METASTATE.
|
||||||
|
|
||||||
|
Заполнить:
|
||||||
|
- Тип проекта
|
||||||
|
- Краткая архитектура (из шага 1.3)
|
||||||
|
- Ключевые модули и их статус
|
||||||
|
- Tech stack
|
||||||
|
- Статус тестов
|
||||||
|
|
||||||
|
### 1.6. Обновить checkpoints
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phases": { "analyse": "completed" },
|
||||||
|
"project_type": "existing | greenfield | scaffold",
|
||||||
|
"last_updated": "<timestamp>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ветвление
|
||||||
|
|
||||||
|
| project_type | Следующая фаза |
|
||||||
|
|---|---|
|
||||||
|
| `existing` | ROADMAP → DECOMPOSITION (DESIGN пропускается) |
|
||||||
|
| `greenfield` | ROADMAP → DESIGN → DECOMPOSITION |
|
||||||
|
| `scaffold` | ROADMAP → DESIGN → DECOMPOSITION |
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/context/analysis-report.md`
|
||||||
|
- `.agent/context/project-state.md` (начальный)
|
||||||
|
- Обновлённый `checkpoints.json`
|
||||||
|
|
||||||
|
## Критерии завершения
|
||||||
|
|
||||||
|
- [ ] Тип проекта определён
|
||||||
|
- [ ] `analysis-report.md` содержит все соответствующие секции
|
||||||
|
- [ ] `project-state.md` создан с начальным слепком
|
||||||
|
- [ ] `checkpoints.json` обновлён
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Протокол 02: Дорожная карта (ROADMAP)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Собрать все источники задач для проекта, приоритизировать их и записать в `.agent/roadmap/sources.md`. ROADMAP — мост между видением проекта и конкретными задачами в манифесте.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/context/analysis-report.md`
|
||||||
|
- Цель сессии (goal из `checkpoints.json` или запрос пользователя)
|
||||||
|
- `FUTURE/` — директория долгосрочных планов (если существует)
|
||||||
|
- `.agent/decisions/index.json` — принятые ADR (опционально)
|
||||||
|
- `.agent/checkpoints.json` (фаза roadmap: pending)
|
||||||
|
- `.agent/rules/project-rules.md` — прочитать первым
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 2.1. Прочитать правила проекта
|
||||||
|
|
||||||
|
Прочитать `.agent/rules/project-rules.md`, применить к фазе.
|
||||||
|
|
||||||
|
### 2.2. Сканировать FUTURE/
|
||||||
|
|
||||||
|
Если в корне проекта существует `FUTURE/`:
|
||||||
|
|
||||||
|
- Прочитать все `.md` файлы.
|
||||||
|
- Зафиксировать: название, статус (active/archived), приоритет, зависимости.
|
||||||
|
- Какие планы реализованы, какие ожидают.
|
||||||
|
|
||||||
|
### 2.3. Сканировать ADR
|
||||||
|
|
||||||
|
Если существует `.agent/decisions/index.json`:
|
||||||
|
|
||||||
|
- Прочитать индекс ADR.
|
||||||
|
- Определить, какие решения требуют реализации (не все ADR технические).
|
||||||
|
- Для каждого — сформулировать задачу-кандидат.
|
||||||
|
|
||||||
|
### 2.4. Собрать внешние источники
|
||||||
|
|
||||||
|
- Запрос пользователя (goal).
|
||||||
|
- issues / feedback (если доступны).
|
||||||
|
- Tech debt, выявленный в ANALYSE.
|
||||||
|
|
||||||
|
### 2.5. Приоритизировать
|
||||||
|
|
||||||
|
Присвоить каждой задаче приоритет:
|
||||||
|
|
||||||
|
| Приоритет | Описание |
|
||||||
|
|---|---|
|
||||||
|
| **P0** | Критично, делать следующим |
|
||||||
|
| **P1** | Важно, сделать скоро |
|
||||||
|
| **P2** | Желательно |
|
||||||
|
| **P3** | Долгосрочно / отложено |
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- Блокирующие зависимости поднимают приоритет.
|
||||||
|
- User-запросы получают P0-P1 по умолчанию.
|
||||||
|
- ADR-задачи получают приоритет по срочности решения.
|
||||||
|
|
||||||
|
### 2.6. Создать sources.md
|
||||||
|
|
||||||
|
Создать `.agent/roadmap/sources.md` по шаблону `TEMPLATES/roadmap-sources.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Roadmap Sources
|
||||||
|
|
||||||
|
## FUTURE Plans
|
||||||
|
| План | Приоритет | Статус |
|
||||||
|
|------|-----------|--------|
|
||||||
|
|
||||||
|
## ADR-Derived Tasks
|
||||||
|
| ADR | Задача | Приоритет |
|
||||||
|
|-----|--------|-----------|
|
||||||
|
|
||||||
|
## User Requests
|
||||||
|
| Запрос | Приоритет | Источник |
|
||||||
|
|--------|-----------|----------|
|
||||||
|
|
||||||
|
## Agent-Identified Improvements
|
||||||
|
| Наблюдение | Задача | Приоритет |
|
||||||
|
|------------|--------|-----------|
|
||||||
|
|
||||||
|
## Consolidated Priority Queue
|
||||||
|
1. task (origin) — P0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7. Архивация
|
||||||
|
|
||||||
|
Если в `.agent/roadmap/archive/` есть предыдущие версии — оставить справочно.
|
||||||
|
|
||||||
|
Если планы из `FUTURE/*` больше не актуальны — переместить в `FUTURE/archive/`.
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/roadmap/sources.md`
|
||||||
|
- Возможно обновлённый `FUTURE/`
|
||||||
|
- `checkpoints.json: phases.roadmap = "completed"`
|
||||||
|
|
||||||
|
## Критерии завершения
|
||||||
|
|
||||||
|
- [ ] Все источники просканированы (FUTURE, ADR, user, agent)
|
||||||
|
- [ ] `sources.md` создан с приоритетами P0-P3
|
||||||
|
- [ ] Каждая задача имеет origin-ссылку на источник
|
||||||
|
- [ ] Устаревшие планы перемещены в archive
|
||||||
|
- [ ] `checkpoints.json` обновлён
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Протокол 03: Архитектурное проектирование (DESIGN)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Спроектировать архитектуру, модули, данные и интерфейсы для greenfield/scaffold-проекта.
|
||||||
|
|
||||||
|
DESIGN выполняется **только** для `project_type = greenfield` или `scaffold`. Для existing-проектов пропускается.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/context/analysis-report.md` (project_type: greenfield или scaffold)
|
||||||
|
- `.agent/roadmap/sources.md` (опционально)
|
||||||
|
- `.agent/checkpoints.json` (фаза design: pending)
|
||||||
|
- `.agent/rules/project-rules.md` — прочитать первым
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
1. **Реалистичность** — архитектура реализуема за 1 сессию (до 10 задач).
|
||||||
|
2. **Документируемость** — каждый модуль, модель и интерфейс описывается в `design-report.md`.
|
||||||
|
3. **Тестируемость** — каждый компонент проектируется с учётом тестирования.
|
||||||
|
4. **Итеративность** — первая версия минимально рабочая (MVP), расширения — отдельными задачами.
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 3.1. Прочитать правила проекта
|
||||||
|
|
||||||
|
Прочитать `.agent/rules/project-rules.md`, применить.
|
||||||
|
|
||||||
|
### 3.2. Технологический стек
|
||||||
|
|
||||||
|
Если стек не указан в README — предложить обоснованный выбор. Если указан — зафиксировать.
|
||||||
|
|
||||||
|
Для каждого компонента:
|
||||||
|
- Язык и версия
|
||||||
|
- Фреймворк / библиотека
|
||||||
|
- База данных (движок, схема)
|
||||||
|
- Инфраструктура (Docker, CI/CD, хостинг)
|
||||||
|
|
||||||
|
### 3.3. High-level архитектура
|
||||||
|
|
||||||
|
- **Паттерн** — монолит, модульный монолит, микросервисы, слоистая, луковая.
|
||||||
|
- **Компоненты** — что делает каждый модуль/сервис.
|
||||||
|
- **Схема взаимодействия** — текстовое описание потоков данных.
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client] → HTTP → [API Gateway] → [Auth Service]
|
||||||
|
↓
|
||||||
|
[Core Service] → [Database]
|
||||||
|
↓
|
||||||
|
[External API] → [3rd Party]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4. Модули
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|---|---|
|
||||||
|
| Имя модуля | `app/services/cashflow.py` |
|
||||||
|
| Ответственность | Что делает |
|
||||||
|
| Ключевые классы/функции | Сигнатуры без реализации |
|
||||||
|
| Зависимости | Какие модули нужны |
|
||||||
|
| Контракт | Что экспортирует |
|
||||||
|
|
||||||
|
### 3.5. Модели данных
|
||||||
|
|
||||||
|
Описать сущности, поля, связи:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entity": "Transaction",
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "type": "UUID", "pk": true},
|
||||||
|
{"name": "amount", "type": "Decimal"},
|
||||||
|
{"name": "date", "type": "datetime"},
|
||||||
|
{"name": "category_id", "type": "UUID", "fk": "Category"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6. API интерфейсы
|
||||||
|
|
||||||
|
| Метод | Путь | Описание | Request | Response | Статусы |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| GET | /transactions | Список | ?page, ?limit | [Transaction] | 200 |
|
||||||
|
| POST | /transactions | Создать | CreateTransactionDTO | Transaction | 201, 400 |
|
||||||
|
|
||||||
|
Если GUI — ключевые страницы. Если CLI — команды.
|
||||||
|
|
||||||
|
### 3.7. Обработка ошибок
|
||||||
|
|
||||||
|
- Стратегия: исключения / Result / коды.
|
||||||
|
- Формат API-ошибок: `{ "error": "...", "code": "...", "details": {} }`.
|
||||||
|
- Логирование: уровни для разных событий.
|
||||||
|
|
||||||
|
### 3.8. Стратегия тестирования
|
||||||
|
|
||||||
|
- Какие тесты нужны (unit, integration, e2e).
|
||||||
|
- Как изолировать зависимости.
|
||||||
|
- Команда запуска тестов.
|
||||||
|
|
||||||
|
### 3.9. Группировка в задачи
|
||||||
|
|
||||||
|
Предварительно наметить задачи по модулям — вход для DECOMPOSITION:
|
||||||
|
|
||||||
|
```
|
||||||
|
T1: Инициализация проекта + зависимости
|
||||||
|
T2: Модель данных (сущности, миграции)
|
||||||
|
T3: Service (core logic)
|
||||||
|
T4: API endpoints
|
||||||
|
T5: Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.10. Создать design-report
|
||||||
|
|
||||||
|
Записать `.agent/context/design-report.md` по шаблону `TEMPLATES/design-report.md`.
|
||||||
|
|
||||||
|
### 3.11. Дополнительно (по команде пользователя)
|
||||||
|
|
||||||
|
Эти шаги **не выполняются автоматически** — только если пользователь явно попросил:
|
||||||
|
|
||||||
|
- **ADR** — вызвать `COMMANDS/adr.md` для ключевых решений.
|
||||||
|
- **Alternative Architecture** — вызвать `COMMANDS/alt-arch.md` для сравнения.
|
||||||
|
- **Risk Register** — вызвать `COMMANDS/risk-register.md` для допущений.
|
||||||
|
- **Red Team** — вызвать `COMMANDS/red-team.md` для атаки на дизайн.
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/context/design-report.md`
|
||||||
|
- Предварительная группировка задач (для DECOMPOSITION)
|
||||||
|
- Возможно: ADR, risk-register, alt-architecture, red-team-report (если вызывали команды)
|
||||||
|
- `checkpoints.json: phases.design = "completed"`
|
||||||
|
|
||||||
|
## Критерии завершения
|
||||||
|
|
||||||
|
- [ ] Стек определён
|
||||||
|
- [ ] High-level архитектура описана
|
||||||
|
- [ ] Модули и их ответственность описаны
|
||||||
|
- [ ] Модели данных спроектированы
|
||||||
|
- [ ] API/интерфейсы описаны (если применимо)
|
||||||
|
- [ ] Стратегия тестирования определена
|
||||||
|
- [ ] Задачи предварительно сгруппированы
|
||||||
|
- [ ] `design-report.md` создан
|
||||||
|
- [ ] `checkpoints.json` обновлён
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Протокол 04: Декомпозиция задач (DECOMPOSITION)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Разбить цель пользователя (и архитектурный план, если есть) на атомарные, независимо выполнимые задачи. Записать в `manifest.json` + `manifest.md`.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/context/analysis-report.md`
|
||||||
|
- `.agent/context/design-report.md` (опционально — для greenfield)
|
||||||
|
- `.agent/roadmap/sources.md` (опционально)
|
||||||
|
- `.agent/decisions/*.md` (опционально)
|
||||||
|
- Цель пользователя (goal из `checkpoints.json`)
|
||||||
|
- `.agent/rules/project-rules.md` — прочитать первым
|
||||||
|
- `.agent/checkpoints.json` (фаза decomposition: pending)
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
1. **Атомарность** — одна задача = одна логическая единица, выполнимая и проверяемая за один подход.
|
||||||
|
2. **Независимость (макс.)** — минимизировать зависимости между задачами.
|
||||||
|
3. **Тестируемость** — каждая задача имеет измеримые acceptance criteria.
|
||||||
|
4. **Границы** — задача не выходит за пределы `BOUNDARIES.md`.
|
||||||
|
5. **Порядок** — задачи с зависимостями выполняются строго последовательно.
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 4.1. Прочитать правила проекта
|
||||||
|
|
||||||
|
Прочитать `.agent/rules/project-rules.md`, применить.
|
||||||
|
|
||||||
|
### 4.2. Размер задачи
|
||||||
|
|
||||||
|
Задача должна укладываться в **1-2 часа работы агента**. Если крупнее — разбить.
|
||||||
|
|
||||||
|
Признак слишком крупной задачи:
|
||||||
|
- Нельзя сформулировать acceptance criteria одной строкой.
|
||||||
|
- Затрагивает 5+ файлов.
|
||||||
|
- Содержит союзы «и», «а также», «после чего».
|
||||||
|
|
||||||
|
### 4.3. Сверить с roadmap
|
||||||
|
|
||||||
|
Если существует `.agent/roadmap/sources.md`:
|
||||||
|
|
||||||
|
- Задачи из roadmap получают приоритет P0-P3 в соответствии с `sources.md`.
|
||||||
|
- Задачи без явного источника получают `origin: "decomposition"`.
|
||||||
|
|
||||||
|
### 4.4. Структура задачи
|
||||||
|
|
||||||
|
| Поле | Описание | Пример |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Уникальный идентификатор | `T1`, `T2` |
|
||||||
|
| `title` | Что сделать | "Добавить модель User" |
|
||||||
|
| `description` | Как и зачем | "Создать SQLAlchemy модель..." |
|
||||||
|
| `type` | Тип | `feature`, `refactor`, `test`, `fix`, `config`, `design`, `docs`, `invariant` |
|
||||||
|
| `status` | Статус | `pending`, `in_progress`, `completed`, `failed`, `archived` |
|
||||||
|
| `origin` | Источник | `roadmap:file`, `adr:NNN`, `user:direct`, `agent:analysis`, `decomposition` |
|
||||||
|
| `files` | Файлы | `["app/models/user.py"]` |
|
||||||
|
| `depends_on` | Зависимости | `[]` или `["T0"]` |
|
||||||
|
| `acceptance_criteria` | 3-5 измеримых пунктов | `["Модель проходит миграцию"]` |
|
||||||
|
| `context` | Доп. информация | `"Смотри app/models/base.py"` |
|
||||||
|
|
||||||
|
**Типы origin:**
|
||||||
|
|
||||||
|
- `roadmap:{filename}` — из FUTURE/ или roadmap
|
||||||
|
- `adr:{NNN}` — из Architecture Decision Record
|
||||||
|
- `user:direct` — от пользователя
|
||||||
|
- `agent:analysis` — выявлено агентом
|
||||||
|
- `decomposition` — создано при декомпозиции
|
||||||
|
- `invariant:{adr_id}` — инвариант для ADR (создаётся командой `/invariant-tests`)
|
||||||
|
- `risk:{R-NNN}` — из Risk Register
|
||||||
|
|
||||||
|
### 4.5. Зелёная декомпозиция (greenfield/scaffold)
|
||||||
|
|
||||||
|
Если есть `design-report.md` — задачи на основе группировки из дизайна:
|
||||||
|
|
||||||
|
1. **T1: init** — инициализация, зависимости, scaffold.
|
||||||
|
2. **T2..Tn: features** — модули по одному.
|
||||||
|
3. **Tn+1: tests** — тесты (можно в составе feature).
|
||||||
|
4. **Tn+2: polish** — документация, форматирование.
|
||||||
|
|
||||||
|
### 4.6. Сортировка
|
||||||
|
|
||||||
|
Задачи в манифесте в порядке выполнения:
|
||||||
|
|
||||||
|
1. Без зависимостей.
|
||||||
|
2. Чьи зависимости уже выполнены.
|
||||||
|
3. С наибольшим числом зависимостей.
|
||||||
|
|
||||||
|
### 4.7. Записать manifest
|
||||||
|
|
||||||
|
Создать `.agent/tasks/manifest.json` по шаблону `TEMPLATES/task-manifest.json`.
|
||||||
|
Создать `.agent/tasks/manifest.md` по шаблону `TEMPLATES/task-manifest.md`.
|
||||||
|
|
||||||
|
### 4.8. Обновить checkpoints
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phases": { "decomposition": "completed" },
|
||||||
|
"tasks": [...],
|
||||||
|
"last_updated": "<timestamp>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/tasks/manifest.json`
|
||||||
|
- `.agent/tasks/manifest.md`
|
||||||
|
- Обновлённый `checkpoints.json`
|
||||||
|
|
||||||
|
## Критерии завершения
|
||||||
|
|
||||||
|
- [ ] Цель разбита на атомарные задачи
|
||||||
|
- [ ] У каждой задачи — acceptance criteria, origin, files
|
||||||
|
- [ ] Зависимости корректны (нет циклов)
|
||||||
|
- [ ] Задачи сверены с roadmap (если `sources.md` существует)
|
||||||
|
- [ ] `manifest.json` и `manifest.md` созданы
|
||||||
|
- [ ] `checkpoints.json` обновлён
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Протокол 05: Исполнение задач (EXECUTION)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Выполнить задачи из `manifest.json`: реализовать код, написать тесты, закоммитить, создать request — артефакт результата.
|
||||||
|
|
||||||
|
EXECUTION — **циклическая** фаза. Работает, пока есть задачи со статусом `pending` и выполненными `depends_on`.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/tasks/manifest.json`
|
||||||
|
- `.agent/context/analysis-report.md`
|
||||||
|
- `.agent/context/design-report.md` (опционально)
|
||||||
|
- `.agent/decisions/*.md` (опционально)
|
||||||
|
- `.agent/rules/project-rules.md` — прочитать первым
|
||||||
|
- `.agent/checkpoints.json` (фаза execution: pending)
|
||||||
|
|
||||||
|
## Шаги (цикл)
|
||||||
|
|
||||||
|
### 5.1. Прочитать правила проекта
|
||||||
|
|
||||||
|
Прочитать `.agent/rules/project-rules.md`, применить.
|
||||||
|
|
||||||
|
### 5.2. Setup окружения (первый запуск)
|
||||||
|
|
||||||
|
Если это первый запуск EXECUTION в сессии:
|
||||||
|
|
||||||
|
- Установить зависимости через штатный пакетный менеджер.
|
||||||
|
- Запустить сборку / базовые тесты.
|
||||||
|
- Записать baseline в `.agent/context/baseline-test-report.log`.
|
||||||
|
|
||||||
|
### 5.3. Выбрать задачу
|
||||||
|
|
||||||
|
Найти в `manifest.json` задачу, удовлетворяющую:
|
||||||
|
|
||||||
|
- `status: "pending"`
|
||||||
|
- Все `depends_on` имеют `status: "completed"` или `"archived"`.
|
||||||
|
|
||||||
|
Если таких нет — EXECUTION завершён, перейти к ожиданию команды пользователя.
|
||||||
|
|
||||||
|
### 5.4. Заблокировать задачу
|
||||||
|
|
||||||
|
В `manifest.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "T1", "status": "in_progress" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5. Исполнить
|
||||||
|
|
||||||
|
- Следовать конвенциям проекта (из ANALYSE).
|
||||||
|
- Соблюдать `BOUNDARIES.md`.
|
||||||
|
- Если задача ссылается на ADR — следовать архитектурному решению.
|
||||||
|
- Писать код + тесты.
|
||||||
|
|
||||||
|
### 5.6. Верифицировать
|
||||||
|
|
||||||
|
- Запустить тесты (все или релевантные).
|
||||||
|
- Проверить LSP diagnostics на изменённых файлах.
|
||||||
|
- Убедиться, что acceptance criteria выполнены.
|
||||||
|
|
||||||
|
### 5.7. Закоммитить
|
||||||
|
|
||||||
|
Сделать git-коммит. Сообщение — суть задачи.
|
||||||
|
|
||||||
|
### 5.8. Создать request
|
||||||
|
|
||||||
|
Создать `.agent/requests/active/req-{task_id}.json` по шаблону `TEMPLATES/request.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "req-T1",
|
||||||
|
"task_id": "T1",
|
||||||
|
"title": "GET /health endpoint",
|
||||||
|
"status": "ready_for_review",
|
||||||
|
"goal": "Добавить ручку GET /health с тестами",
|
||||||
|
"changes": {
|
||||||
|
"summary": "Создан health router, подключён в main.py, написаны тесты",
|
||||||
|
"commits": ["abc1234"],
|
||||||
|
"files_changed": ["app/routers/health.py", "app/main.py", "tests/test_health.py"]
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"tests_passed": "24/24",
|
||||||
|
"lsp_clean": true
|
||||||
|
},
|
||||||
|
"fulfills_ac": ["Ручка возвращает 200 + {\"status\":\"ok\"}"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Request фиксирует:
|
||||||
|
- **summary** — суть изменений (не diff).
|
||||||
|
- **commits** — ссылки на коммиты.
|
||||||
|
- **files_changed** — какие файлы.
|
||||||
|
- **verification** — тесты + LSP.
|
||||||
|
- **fulfills_ac** — какие acceptance criteria закрыты.
|
||||||
|
|
||||||
|
### 5.9. Завершить задачу
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "T1", "status": "completed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.10. Цикл
|
||||||
|
|
||||||
|
Перейти к шагу 5.3. Если задач больше нет — сообщить пользователю и ожидать команду (METASTATE, новая задача, или завершение).
|
||||||
|
|
||||||
|
## Request как единица результата
|
||||||
|
|
||||||
|
Не просто «задача сделана», а документированный результат. Request проходит ревью в фазе METASTATE:
|
||||||
|
|
||||||
|
- `ready_for_review` → после проверки → `approved` или `rejected`.
|
||||||
|
|
||||||
|
## Команды во время EXECUTION
|
||||||
|
|
||||||
|
В любой момент цикла пользователь может вызвать:
|
||||||
|
|
||||||
|
- **/adr** — зафиксировать архитектурное решение, появившееся в процессе.
|
||||||
|
- **/red-team** — попытаться сломать текущий подход.
|
||||||
|
- **/risk-register** — зафиксировать новый риск.
|
||||||
|
|
||||||
|
Команды не прерывают EXECUTION, но могут добавить задачи в manifest.
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- Выполненные задачи в `manifest.json` (status: completed)
|
||||||
|
- `.agent/requests/active/req-{task_id}.json` для каждой выполненной задачи
|
||||||
|
- Обновлённый `checkpoints.json`
|
||||||
|
|
||||||
|
## Критерии завершения (одна итерация)
|
||||||
|
|
||||||
|
- [ ] Acceptance criteria выполнены
|
||||||
|
- [ ] Тесты проходят
|
||||||
|
- [ ] LSP diagnostics чист
|
||||||
|
- [ ] Коммит создан
|
||||||
|
- [ ] Request создан в `.agent/requests/active/`
|
||||||
|
- [ ] Задача в `manifest.json` отмечена completed
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Протокол 06: Обновление метасостояния (METASTATE)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
По команде пользователя провести ревью накопленных requests, синхронизировать манифест, обновить слепок проекта и подготовить `.agent/` как полную картину для следующей сессии.
|
||||||
|
|
||||||
|
## Когда запускать
|
||||||
|
|
||||||
|
По команде пользователя:
|
||||||
|
|
||||||
|
- «обнови метасостояние»
|
||||||
|
- «update metastate»
|
||||||
|
- «подведи итог»
|
||||||
|
- «заверши сессию»
|
||||||
|
|
||||||
|
Может запускаться многократно — после каждой группы выполненных задач.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/requests/active/` — все request-ы со статусом `ready_for_review`
|
||||||
|
- `.agent/tasks/manifest.json`
|
||||||
|
- `.agent/context/project-state.md` (создан в ANALYSE, обновляется здесь)
|
||||||
|
- `.agent/roadmap/sources.md`
|
||||||
|
- `.agent/decisions/index.json`
|
||||||
|
- `.agent/checkpoints.json`
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 6.1. Собрать requests
|
||||||
|
|
||||||
|
Прочитать все файлы из `.agent/requests/active/` со статусом `ready_for_review`.
|
||||||
|
|
||||||
|
### 6.2. Ревью каждого request
|
||||||
|
|
||||||
|
Для каждого:
|
||||||
|
|
||||||
|
1. **Верифицировать** — тесты проходят, LSP чист, AC выполнены, коммиты на месте.
|
||||||
|
2. **Принять или отклонить:**
|
||||||
|
|
||||||
|
- ✅ **approved**:
|
||||||
|
- Переместить в `.agent/requests/archive/`.
|
||||||
|
- В `manifest.json` убедиться: `status: "completed"`.
|
||||||
|
|
||||||
|
- ❌ **rejected**:
|
||||||
|
- Оставить в `active/` с комментарием.
|
||||||
|
- В `manifest.json`: `status: "reopened"`, добавить `rejection_reason`.
|
||||||
|
- В request добавить `rejection_reason`.
|
||||||
|
|
||||||
|
### 6.3. Архивация завершённых задач
|
||||||
|
|
||||||
|
Для каждой `completed` задачи:
|
||||||
|
|
||||||
|
1. Создать `.agent/archive/tasks/{id}.json` — полное описание.
|
||||||
|
2. В `manifest.json` заменить на one-liner:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "T1", "title": "GET /health endpoint", "status": "archived", "origin": "user:direct" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4. Обновить project-state
|
||||||
|
|
||||||
|
Переписать `.agent/context/project-state.md` с учётом выполненных задач:
|
||||||
|
|
||||||
|
- Обновить список модулей (добавлены / изменены).
|
||||||
|
- Обновить архитектурную схему (кратко).
|
||||||
|
- Обновить статус тестов.
|
||||||
|
- Добавить новые ADR.
|
||||||
|
- Убрать закрытые concerns.
|
||||||
|
|
||||||
|
**Цель:** следующий агент читает `project-state.md` и понимает проект, не открывая исходники.
|
||||||
|
|
||||||
|
### 6.5. Обновить roadmap
|
||||||
|
|
||||||
|
В `.agent/roadmap/sources.md`:
|
||||||
|
|
||||||
|
- Отметить выполненные пункты.
|
||||||
|
- Пересчитать приоритеты.
|
||||||
|
- Добавить новые источники (если появились).
|
||||||
|
|
||||||
|
### 6.6. Индекс архива
|
||||||
|
|
||||||
|
Создать/обновить `.agent/archive/index.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "3.0.0",
|
||||||
|
"archived_at": "<timestamp>",
|
||||||
|
"tasks": [{ "id": "T1", "title": "...", "archived_at": "<timestamp>" }],
|
||||||
|
"requests": [{ "id": "req-T1", "task_id": "T1", "archived_at": "<timestamp>" }],
|
||||||
|
"checkpoints": [{ "file": "checkpoints/<ts>.json", "archived_at": "<timestamp>" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7. Создать handoff-summary
|
||||||
|
|
||||||
|
Создать `.agent/handoff-summary.md` — полная сводка для следующего агента:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Session Summary
|
||||||
|
**Session:** <id>
|
||||||
|
**Goal:** <goal>
|
||||||
|
**Completed:** N tasks
|
||||||
|
**Pending:** M tasks
|
||||||
|
**Approved requests:** req-T1, req-T2
|
||||||
|
|
||||||
|
## Project State
|
||||||
|
(краткая выжимка из project-state.md)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
(с чего начать следующую сессию)
|
||||||
|
|
||||||
|
## Key Artifacts
|
||||||
|
- Project state: `.agent/context/project-state.md`
|
||||||
|
- Tasks: `.agent/tasks/manifest.json`
|
||||||
|
- Roadmap: `.agent/roadmap/sources.md`
|
||||||
|
- Pending reviews: `.agent/requests/active/`
|
||||||
|
- Archive: `.agent/archive/index.json`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.8. Обновить checkpoints
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "phases": { "metastate": "completed" }, "last_updated": "<timestamp>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/requests/archive/` — подтверждённые request-ы
|
||||||
|
- `.agent/archive/tasks/{id}.json` — архив задач
|
||||||
|
- Обновлённый `.agent/context/project-state.md`
|
||||||
|
- Обновлённый `.agent/roadmap/sources.md`
|
||||||
|
- `.agent/handoff-summary.md`
|
||||||
|
- `.agent/archive/index.json`
|
||||||
|
- Финальный `checkpoints.json`
|
||||||
|
|
||||||
|
## Критерии завершения
|
||||||
|
|
||||||
|
- [ ] Все `ready_for_review` requests проверены (approved / rejected)
|
||||||
|
- [ ] Approved перемещены в archive
|
||||||
|
- [ ] Completed задачи архивированы (one-liner в manifest)
|
||||||
|
- [ ] `project-state.md` отражает актуальное состояние
|
||||||
|
- [ ] `roadmap/sources.md` обновлён
|
||||||
|
- [ ] `archive/index.json` создан
|
||||||
|
- [ ] `handoff-summary.md` готов
|
||||||
|
- [ ] `checkpoints.json` финализирован
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Протокол 07: Завершение сессии (HANDOFF)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Финализация сессии: валидация структуры `.agent/`, финальный `session-summary.md`, отметка `phases.handoff = "completed"`.
|
||||||
|
|
||||||
|
> Если перед HANDOFF был METASTATE — архивация, project-state, handoff-summary уже готовы. HANDOFF только валидирует и финализирует.
|
||||||
|
|
||||||
|
## Вход
|
||||||
|
|
||||||
|
- `.agent/checkpoints.json` (все фазы кроме handoff: completed или skipped)
|
||||||
|
- Все артефакты `.agent/`
|
||||||
|
|
||||||
|
## Шаги
|
||||||
|
|
||||||
|
### 7.1. Проверить: был ли METASTATE?
|
||||||
|
|
||||||
|
Если существуют `.agent/handoff-summary.md` и `.agent/context/project-state.md` (обновлён) — METASTATE выполнен. Перейти к шагу 7.3.
|
||||||
|
|
||||||
|
Если нет — выполнить лёгкую архивацию (шаг 7.2).
|
||||||
|
|
||||||
|
### 7.2. Лёгкая архивация (если METASTATE не было)
|
||||||
|
|
||||||
|
Если есть `completed` задачи в `manifest.json`:
|
||||||
|
|
||||||
|
- Архивировать в `.agent/archive/tasks/{id}.json`.
|
||||||
|
- Заменить в `manifest.json` на one-liner.
|
||||||
|
- Создать `.agent/archive/index.json`.
|
||||||
|
|
||||||
|
### 7.3. Валидация
|
||||||
|
|
||||||
|
Проверить:
|
||||||
|
|
||||||
|
- [ ] Все фазы в `checkpoints.json` отмечены `completed` или `skipped`.
|
||||||
|
- [ ] `.agent/` содержит обязательные файлы:
|
||||||
|
- `checkpoints.json`
|
||||||
|
- `context/analysis-report.md`
|
||||||
|
- `context/project-state.md`
|
||||||
|
- `tasks/manifest.json` + `manifest.md`
|
||||||
|
- `rules/project-rules.md`
|
||||||
|
- `src/GUIDE.md`
|
||||||
|
- `src/BOUNDARIES.md`
|
||||||
|
- `src/VERSION`
|
||||||
|
- `src/PROTOCOLS/`
|
||||||
|
- `src/COMMANDS/`
|
||||||
|
- `src/TEMPLATES/`
|
||||||
|
- [ ] В `manifest.json` нет циклических зависимостей.
|
||||||
|
- [ ] У каждой задачи — measurable acceptance criteria и origin.
|
||||||
|
- [ ] `AGENTS.md` присутствует в корне репозитория.
|
||||||
|
|
||||||
|
### 7.4. Создать session-summary
|
||||||
|
|
||||||
|
Создать `.agent/session-summary.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Session Summary
|
||||||
|
|
||||||
|
**Session:** <id>
|
||||||
|
**MetaAgent version:** 3.0.0
|
||||||
|
**Date:** <timestamp>
|
||||||
|
**Goal:** <goal>
|
||||||
|
|
||||||
|
## Phases Executed
|
||||||
|
- [x] INIT
|
||||||
|
- [x] ANALYSE
|
||||||
|
- [x] ROADMAP
|
||||||
|
- [x] DESIGN (или skipped)
|
||||||
|
- [x] DECOMPOSITION
|
||||||
|
- [x] EXECUTION (N tasks)
|
||||||
|
- [x] METASTATE (или skipped)
|
||||||
|
- [x] HANDOFF
|
||||||
|
|
||||||
|
## Results
|
||||||
|
- Tasks completed: N
|
||||||
|
- Requests approved: N
|
||||||
|
- Files changed: [list]
|
||||||
|
|
||||||
|
## Next
|
||||||
|
Следующий агент: читай `.agent/handoff-summary.md`.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5. Финализировать checkpoints
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "phases": { "handoff": "completed" }, "last_updated": "<timestamp>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6. Сигнал
|
||||||
|
|
||||||
|
```
|
||||||
|
HANDOFF COMPLETE
|
||||||
|
|
||||||
|
Session: <session_id>
|
||||||
|
Target: <target_repo>
|
||||||
|
Type: <existing | greenfield | scaffold>
|
||||||
|
Tasks: <N> total, <M> completed, <K> pending
|
||||||
|
|
||||||
|
Следующий агент начинает с .agent/handoff-summary.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Выход
|
||||||
|
|
||||||
|
- `.agent/session-summary.md`
|
||||||
|
- Финальный `.agent/checkpoints.json`
|
||||||
|
- (если METASTATE не было) `.agent/archive/index.json`
|
||||||
|
|
||||||
|
## Критерии завершения
|
||||||
|
|
||||||
|
- [ ] Все артефакты на месте
|
||||||
|
- [ ] (если METASTATE не было) `completed` задачи архивированы
|
||||||
|
- [ ] `session-summary.md` создан
|
||||||
|
- [ ] `checkpoints.json` финализирован
|
||||||
|
- [ ] Сигнал отправлен пользователю
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR-NNNN: <Заголовок решения>
|
||||||
|
|
||||||
|
**Статус:** proposed | accepted | deprecated | superseded
|
||||||
|
|
||||||
|
**Дата:** {{ date }}
|
||||||
|
|
||||||
|
**Контекст:** почему возникла необходимость в решении, какая проблема решается.
|
||||||
|
|
||||||
|
**Рассматриваемые альтернативы:**
|
||||||
|
1. Вариант A — описание
|
||||||
|
2. Вариант B — описание
|
||||||
|
3. Вариант C — описание
|
||||||
|
|
||||||
|
**Решение:** выбран вариант <A/B/C>.
|
||||||
|
|
||||||
|
**Обоснование:** почему выбран именно этот вариант (критерии: сложность, поддерживаемость, производительность, совместимость).
|
||||||
|
|
||||||
|
**Последствия:**
|
||||||
|
- Позитивные: ...
|
||||||
|
- Негативные: ...
|
||||||
|
- Риски: ...
|
||||||
|
|
||||||
|
**Invariant (если применимо):** ключевое правило, которое не должен нарушать исполнительный агент. Если можно — ссылка на тест, проверяющий invariant.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Analysis Report
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
- **Session ID:** `{{ session_id }}`
|
||||||
|
- **Target repo:** `{{ target_repo }}`
|
||||||
|
- **Date:** {{ date }}
|
||||||
|
- **Project type:** `{{ project_type }}` (existing / greenfield / scaffold)
|
||||||
|
|
||||||
|
## 1. Общая информация
|
||||||
|
|
||||||
|
- **README:** {{ readme_summary }}
|
||||||
|
- **Лицензия:** {{ license }}
|
||||||
|
- **CI/CD:** {{ ci_cd }}
|
||||||
|
- **Точка входа:** {{ entry_point }}
|
||||||
|
- **Система сборки:** {{ build_system }}
|
||||||
|
|
||||||
|
## 2. Стек технологий (existing / scaffold)
|
||||||
|
|
||||||
|
| Компонент | Значение |
|
||||||
|
|---|---|
|
||||||
|
| Язык | {{ language }} |
|
||||||
|
| Фреймворк | {{ framework }} |
|
||||||
|
| База данных | {{ database }} |
|
||||||
|
| Тестовый раннер | {{ test_runner }} |
|
||||||
|
| Пакетный менеджер | {{ package_manager }} |
|
||||||
|
| Линтер/форматтер | {{ linter }} |
|
||||||
|
|
||||||
|
## 3. Архитектура (existing / scaffold)
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ directory_tree }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Паттерн:** {{ architecture_pattern }}
|
||||||
|
|
||||||
|
**Ключевые модули:**
|
||||||
|
|
||||||
|
| Модуль | Описание |
|
||||||
|
|---|---|
|
||||||
|
| {{ module }} | {{ description }} |
|
||||||
|
|
||||||
|
## 4. Конвенции (existing / scaffold)
|
||||||
|
|
||||||
|
- **Стиль:** {{ code_style }}
|
||||||
|
- **Импорты:** {{ import_style }}
|
||||||
|
- **Типизация:** {{ typing_usage }}
|
||||||
|
- **Обработка ошибок:** {{ error_handling }}
|
||||||
|
- **Логирование:** {{ logging }}
|
||||||
|
|
||||||
|
## 5. Тесты (existing / scaffold)
|
||||||
|
|
||||||
|
- **Команда запуска:** `{{ test_command }}`
|
||||||
|
- **Всего тестов:** {{ total_tests }}
|
||||||
|
- **Пройдено:** {{ passed }}
|
||||||
|
- **Упало:** {{ failed }}
|
||||||
|
- **Пропущено:** {{ skipped }}
|
||||||
|
- **Упавшие тесты:** {{ failed_tests_list }}
|
||||||
|
|
||||||
|
## 6. Базовая проверка (existing / scaffold)
|
||||||
|
|
||||||
|
- **Сборка:** {{ build_status }}
|
||||||
|
- **Запуск:** {{ run_status }}
|
||||||
|
- **Git status:** {{ git_status }}
|
||||||
|
|
||||||
|
## 7. Требования (greenfield / scaffold)
|
||||||
|
|
||||||
|
### Функциональные требования
|
||||||
|
|
||||||
|
{{ functional_requirements_list }}
|
||||||
|
|
||||||
|
### Нефункциональные требования
|
||||||
|
|
||||||
|
{{ non_functional_requirements_list }}
|
||||||
|
|
||||||
|
### Бизнес-контекст
|
||||||
|
|
||||||
|
{{ business_context_list }}
|
||||||
|
|
||||||
|
### Неясные моменты / Вопросы
|
||||||
|
|
||||||
|
{{ open_questions_list }}
|
||||||
|
|
||||||
|
## 8. Примечания
|
||||||
|
|
||||||
|
{{ notes }}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Design Report
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
- **Session ID:** `{{ session_id }}`
|
||||||
|
- **Target repo:** `{{ target_repo }}`
|
||||||
|
- **Date:** {{ date }}
|
||||||
|
|
||||||
|
## 1. Технологический стек
|
||||||
|
|
||||||
|
| Компонент | Выбор | Обоснование |
|
||||||
|
|---|---|---|
|
||||||
|
| Язык | {{ language }} | {{ language_rationale }} |
|
||||||
|
| Фреймворк | {{ framework }} | {{ framework_rationale }} |
|
||||||
|
| База данных | {{ database }} | {{ database_rationale }} |
|
||||||
|
| Инфраструктура | {{ infrastructure }} | {{ infrastructure_rationale }} |
|
||||||
|
|
||||||
|
## 2. High-Level архитектура
|
||||||
|
|
||||||
|
**Паттерн:** {{ architecture_pattern }}
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ architecture_diagram }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поток данных:**
|
||||||
|
1. {{ data_flow_step_1 }}
|
||||||
|
2. {{ data_flow_step_2 }}
|
||||||
|
3. {{ data_flow_step_3 }}
|
||||||
|
|
||||||
|
## 3. Модули
|
||||||
|
|
||||||
|
| Модуль | Ответственность | Ключевые компоненты | Зависит от |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `{{ module_path }}` | {{ responsibility }} | {{ components }} | {{ dependencies }} |
|
||||||
|
|
||||||
|
## 4. Модели данных
|
||||||
|
|
||||||
|
### Сущности
|
||||||
|
|
||||||
|
{{ entity_descriptions }}
|
||||||
|
|
||||||
|
## 5. API / Интерфейсы
|
||||||
|
|
||||||
|
{{ api_endpoints_table }}
|
||||||
|
|
||||||
|
## 6. Обработка ошибок
|
||||||
|
|
||||||
|
- **Стратегия:** {{ error_strategy }}
|
||||||
|
- **Формат ошибок:** {{ error_format }}
|
||||||
|
- **Логирование:** {{ logging_strategy }}
|
||||||
|
|
||||||
|
## 7. Тестирование
|
||||||
|
|
||||||
|
- **Unit-тесты:** {{ unit_test_strategy }}
|
||||||
|
- **Integration-тесты:** {{ integration_test_strategy }}
|
||||||
|
- **Mock-стратегия:** {{ mock_strategy }}
|
||||||
|
- **Команда запуска:** `{{ test_command }}`
|
||||||
|
|
||||||
|
## 8. Дополнительные артефакты (по команде пользователя)
|
||||||
|
|
||||||
|
Если пользователь вызвал соответствующие команды, добавить ссылки:
|
||||||
|
|
||||||
|
- ADR: `.agent/decisions/` (команда `/adr`)
|
||||||
|
- Alternative Architecture: `.agent/context/alt-architecture.md` (команда `/alt-arch`)
|
||||||
|
- Risk Register: `.agent/context/risk-register.md` (команда `/risk-register`)
|
||||||
|
- Red Team Review: `.agent/context/red-team-report.md` (команда `/red-team`)
|
||||||
|
|
||||||
|
## 9. Предварительная группировка задач
|
||||||
|
|
||||||
|
| Задача | Описание | Тип |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | {{ task_1 }} | config |
|
||||||
|
| T2 | {{ task_2 }} | feature |
|
||||||
|
| T3 | {{ task_3 }} | feature |
|
||||||
|
| T4 | {{ task_4 }} | test |
|
||||||
|
|
||||||
|
## 10. Примечания
|
||||||
|
|
||||||
|
{{ notes }}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Handoff Summary
|
||||||
|
|
||||||
|
## Session Info
|
||||||
|
|
||||||
|
- **Session ID:** `{{ session_id }}`
|
||||||
|
- **Target Repo:** {{ target_repo }}
|
||||||
|
- **Goal:** {{ goal }}
|
||||||
|
- **Date:** {{ date }}
|
||||||
|
- **Project type:** {{ project_type }}
|
||||||
|
|
||||||
|
## Repo Summary
|
||||||
|
|
||||||
|
{{ repo_summary }}
|
||||||
|
|
||||||
|
## Artifacts Created
|
||||||
|
|
||||||
|
- **Analysis report:** `.agent/context/analysis-report.md`
|
||||||
|
- **Project state:** `.agent/context/project-state.md`
|
||||||
|
- **Design report:** {{ design_report_path_or_dash }}
|
||||||
|
- **Roadmap:** `.agent/roadmap/sources.md`
|
||||||
|
- **ADR:** {{ adr_summary_or_dash }}
|
||||||
|
- **Risk Register:** {{ risk_register_path_or_dash }}
|
||||||
|
- **Red Team Report:** {{ red_team_report_path_or_dash }}
|
||||||
|
|
||||||
|
## Environment Status
|
||||||
|
|
||||||
|
- **Build:** {{ build_status }}
|
||||||
|
- **Tests:** {{ tests_passed }}/{{ tests_total }} passed
|
||||||
|
- **Baseline log:** `.agent/context/baseline-test-report.log`
|
||||||
|
- **Dependencies:** {{ deps_status }}
|
||||||
|
|
||||||
|
## Task Overview
|
||||||
|
|
||||||
|
| Status | Count |
|
||||||
|
|---|---|
|
||||||
|
| Total | {{ total }} |
|
||||||
|
| Pending | {{ pending }} |
|
||||||
|
| In Progress | {{ in_progress }} |
|
||||||
|
| Completed | {{ completed }} |
|
||||||
|
| Archived | {{ archived }} |
|
||||||
|
| Failed/Skipped | {{ failed }} |
|
||||||
|
|
||||||
|
## Tasks (ordered)
|
||||||
|
|
||||||
|
{{ task_list_markdown }}
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Следующий агент: прочитай `.agent/context/project-state.md`, затем `.agent/tasks/manifest.json` и приступай к первой `pending` задаче.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
{{ caveats_list }}
|
||||||
|
|
||||||
|
## Checkpoints
|
||||||
|
|
||||||
|
Файл: `.agent/checkpoints.json` — состояние фаз и список задач.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Project Rules
|
||||||
|
|
||||||
|
Правила, которым агент обязан следовать во всех фазах.
|
||||||
|
Добавляйте сюда условия, которые должны соблюдаться всегда — они будут прочитаны
|
||||||
|
перед началом каждой фазы и учтены при декомпозиции и реализации.
|
||||||
|
|
||||||
|
## Обязательные правила
|
||||||
|
|
||||||
|
- (укажите правила, например: «Всегда использовать tabs для отступов»)
|
||||||
|
|
||||||
|
## Запреты
|
||||||
|
|
||||||
|
- (укажите запреты, например: «Не трогать CI/CD конфигурацию»)
|
||||||
|
|
||||||
|
## Конвенции проекта
|
||||||
|
|
||||||
|
- (укажите конвенции, например: «Имена классов в PascalCase, функции в snake_case»)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Project State
|
||||||
|
# Auto-generated — updated by ANALYSE (initial) and METASTATE (on updates)
|
||||||
|
|
||||||
|
**Last updated:** {{ timestamp }}
|
||||||
|
**Session:** {{ session_id }}
|
||||||
|
|
||||||
|
## Project Type
|
||||||
|
|
||||||
|
{{ project_type }}
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Category | Technology |
|
||||||
|
|----------|-----------|
|
||||||
|
| Language | {{ language }} |
|
||||||
|
| Framework | {{ framework }} |
|
||||||
|
| Database | {{ database }} |
|
||||||
|
| Test runner | {{ test_runner }} |
|
||||||
|
| Package manager | {{ package_manager }} |
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
{{ architecture_description }}
|
||||||
|
|
||||||
|
## Key Modules
|
||||||
|
|
||||||
|
| Module | Status | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| {{ module_name }} | {{ existing / stub / new }} | {{ module_description }} |
|
||||||
|
|
||||||
|
## Decisions in Effect
|
||||||
|
|
||||||
|
| ADR | Decision | Status |
|
||||||
|
|-----|----------|--------|
|
||||||
|
| {{ adr_id }} | {{ decision_summary }} | {{ active / superseded }} |
|
||||||
|
|
||||||
|
## Testing Status
|
||||||
|
|
||||||
|
{{ testing_summary }}
|
||||||
|
|
||||||
|
## Open Concerns
|
||||||
|
|
||||||
|
- {{ concern_1 }}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": ".agent/src/TEMPLATES/schemas/request-schema.json",
|
||||||
|
"template_version": "1.0",
|
||||||
|
"request_id": "req-{{ task_id }}",
|
||||||
|
"task_id": "{{ task_id }}",
|
||||||
|
"title": "{{ task_title }}",
|
||||||
|
"status": "ready_for_review",
|
||||||
|
"created_at": "{{ timestamp }}",
|
||||||
|
"goal": "{{ task_goal }}",
|
||||||
|
|
||||||
|
"changes": {
|
||||||
|
"summary": "{{ changes_summary }}",
|
||||||
|
"commits": [
|
||||||
|
"{{ commit_hash }}"
|
||||||
|
],
|
||||||
|
"files_changed": [
|
||||||
|
"path/to/file.py"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"verification": {
|
||||||
|
"tests_passed": "{{ test_results }}",
|
||||||
|
"lsp_clean": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"fulfills_ac": [
|
||||||
|
"{{ acceptance_criterion }}"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Risk Register
|
||||||
|
|
||||||
|
| # | Assumption | Impact if wrong | Mitigation | Review trigger |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| R1 | Пользователи имеют Python 3.11+ | Проект не запускается на старых версиях | Указать требование в README, CI-проверка | При жалобе на установку |
|
||||||
|
| R2 | JSON-файлы не превышают 10MB | Деградация производительности | Добавить лимит в model.py | При первом замедлении |
|
||||||
|
| R3 | ... | ... | ... | ... |
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Roadmap Sources
|
||||||
|
# Auto-generated — created by ROADMAP phase
|
||||||
|
|
||||||
|
**Created:** {{ timestamp }}
|
||||||
|
**Session:** {{ session_id }}
|
||||||
|
|
||||||
|
## Priority Legend
|
||||||
|
|
||||||
|
- **P0** — Critical, do next
|
||||||
|
- **P1** — Important, do soon
|
||||||
|
- **P2** — Nice to have
|
||||||
|
- **P3** — Future / deferred
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### FUTURE Plans
|
||||||
|
|
||||||
|
| Plan | Priority | Status | Origin File |
|
||||||
|
|------|----------|--------|-------------|
|
||||||
|
| {{ plan_title }} | {{ P0-P3 }} | {{ active / archived }} | FUTURE/{{ filename }}.md |
|
||||||
|
|
||||||
|
### ADR-Derived Tasks
|
||||||
|
|
||||||
|
| Source ADR | Task | Priority |
|
||||||
|
|------------|------|----------|
|
||||||
|
| {{ adr_id }} | {{ task_description }} | {{ P0-P3 }} |
|
||||||
|
|
||||||
|
### User Requests
|
||||||
|
|
||||||
|
| Request | Priority | Source |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| {{ request }} | {{ P0-P3 }} | {{ direct / issue / feedback }} |
|
||||||
|
|
||||||
|
### Agent-Identified Improvements
|
||||||
|
|
||||||
|
| Observation | Suggested Task | Priority |
|
||||||
|
|-------------|----------------|----------|
|
||||||
|
| {{ observation }} | {{ task }} | {{ P0-P3 }} |
|
||||||
|
|
||||||
|
## Consolidated Priority Queue
|
||||||
|
|
||||||
|
1. **{{ task_title }}** ({{ origin }}) — {{ priority }}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "metaagent/checkpoints/3.0.0",
|
||||||
|
"title": "MetaAgent Checkpoints",
|
||||||
|
"description": "Schema for .agent/checkpoints.json — session state",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"metaagent_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "MetaAgent version that created this checkpoint",
|
||||||
|
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique session identifier"
|
||||||
|
},
|
||||||
|
"target_repo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the target repository"
|
||||||
|
},
|
||||||
|
"goal": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "Session goal (set by user, may be null until first task)"
|
||||||
|
},
|
||||||
|
"project_type": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": [null, "existing", "greenfield", "scaffold"],
|
||||||
|
"description": "Type of the target project (set in ANALYSE phase)"
|
||||||
|
},
|
||||||
|
"phases": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"init": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
|
||||||
|
"analyse": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
|
||||||
|
"roadmap": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
|
||||||
|
"design": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
|
||||||
|
"decomposition": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
|
||||||
|
"execution": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
|
||||||
|
"metastate": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
|
||||||
|
"handoff": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "List of tasks (one-liners after archiving)",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"title": { "type": "string" },
|
||||||
|
"status": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "archived"] }
|
||||||
|
},
|
||||||
|
"required": ["id", "title", "status"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last_updated": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "ISO 8601 timestamp of last update"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["metaagent_version", "session_id", "phases", "last_updated"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "metaagent/decisions-index/3.0.0",
|
||||||
|
"title": "MetaAgent Decisions Index",
|
||||||
|
"description": "Schema for .agent/decisions/index.json — machine-readable index of ADRs",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Schema version (3.0 in v3.0+, 2.0 still valid from v2.1)",
|
||||||
|
"enum": ["2.0", "3.0"]
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "List of architecture decision records",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]{3,}$",
|
||||||
|
"description": "ADR number (001, 002, ...)"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short title of the decision"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["proposed", "accepted", "deprecated", "superseded"],
|
||||||
|
"description": "ADR status"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filename in .agent/decisions/ (e.g. 001-stack.md)"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Decision date (ISO 8601)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id", "title", "file"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "ISO 8601 timestamp of index creation"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "ISO 8601 timestamp of last update"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["version", "decisions"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "metaagent/task-manifest/3.0.0",
|
||||||
|
"title": "MetaAgent Task Manifest",
|
||||||
|
"description": "Schema for .agent/tasks/manifest.json — the global task manifest",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Schema version",
|
||||||
|
"enum": ["3.0"]
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Session ID that created this manifest"
|
||||||
|
},
|
||||||
|
"goal": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Overall goal of the session"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "ISO 8601 timestamp of creation"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "List of tasks",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(T[0-9]+|T-INV-[0-9]+)$",
|
||||||
|
"description": "Unique task identifier (T1, T2, ... or T-INV-N for invariants)"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Task title"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Detailed description"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["feature", "refactor", "test", "fix", "config", "design", "docs", "invariant"],
|
||||||
|
"description": "Task type"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Task source (roadmap:file, adr:NNN, user:direct, agent:analysis, decomposition, invariant:NNN, risk:R-NNN)"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Files affected by this task"
|
||||||
|
},
|
||||||
|
"depends_on": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Task IDs this task depends on"
|
||||||
|
},
|
||||||
|
"acceptance_criteria": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Measurable criteria for completion"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Additional context or references"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["pending", "in_progress", "completed", "failed", "archived"],
|
||||||
|
"description": "Current task status"
|
||||||
|
},
|
||||||
|
"claimed_by": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Worker ID if claimed"
|
||||||
|
},
|
||||||
|
"claimed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When the task was claimed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id", "title", "type", "status"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["version", "tasks"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Session Summary
|
||||||
|
|
||||||
|
**Session:** {{ session_id }}
|
||||||
|
**Target:** {{ target_repo }}
|
||||||
|
**MetaAgent version:** {{ version }}
|
||||||
|
**Date:** {{ date }}
|
||||||
|
|
||||||
|
## Phase Status
|
||||||
|
|
||||||
|
| Phase | Status |
|
||||||
|
|---|---|
|
||||||
|
| INIT | {{ init_status }} |
|
||||||
|
| ANALYSE | {{ analyse_status }} |
|
||||||
|
| ROADMAP | {{ roadmap_status }} |
|
||||||
|
| DESIGN | {{ design_status }} |
|
||||||
|
| DECOMPOSITION | {{ decomposition_status }} |
|
||||||
|
| EXECUTION | {{ execution_status }} |
|
||||||
|
| METASTATE | {{ metastate_status }} |
|
||||||
|
| HANDOFF | {{ handoff_status }} |
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| Status | Count |
|
||||||
|
|---|---|
|
||||||
|
| Total | {{ total }} |
|
||||||
|
| Pending | {{ pending }} |
|
||||||
|
| In Progress | {{ in_progress }} |
|
||||||
|
| Completed | {{ completed }} |
|
||||||
|
| Archived | {{ archived }} |
|
||||||
|
| Failed/Skipped | {{ failed }} |
|
||||||
|
|
||||||
|
**By origin:**
|
||||||
|
- user:direct: {{ user_direct_count }}
|
||||||
|
- roadmap: {{ roadmap_count }}
|
||||||
|
- adr: {{ adr_count }}
|
||||||
|
- decomposition: {{ decomposition_count }}
|
||||||
|
- (другое): {{ other_count }}
|
||||||
|
|
||||||
|
## Commands Invoked (если были)
|
||||||
|
|
||||||
|
{{ commands_invoked_list }}
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
- Task Manifest: `.agent/tasks/manifest.json`
|
||||||
|
- Handoff Summary: `.agent/handoff-summary.md`
|
||||||
|
- Project State: `.agent/context/project-state.md`
|
||||||
|
- ADR: `.agent/decisions/`
|
||||||
|
- Risk Register: `.agent/context/risk-register.md`
|
||||||
|
- Red Team Report: `.agent/context/red-team-report.md`
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": ".agent/src/TEMPLATES/schemas/task-manifest-schema.json",
|
||||||
|
"version": "3.0",
|
||||||
|
"session_id": "{{ session_id }}",
|
||||||
|
"goal": "{{ goal }}",
|
||||||
|
"created_at": "{{ timestamp }}",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "T1",
|
||||||
|
"title": "{{ task_title }}",
|
||||||
|
"description": "{{ task_description }}",
|
||||||
|
"type": "feature|refactor|test|fix|config|design|docs|invariant",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"files": ["path/to/file1.py", "path/to/file2.py"],
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Критерий 1: ...",
|
||||||
|
"Критерий 2: ..."
|
||||||
|
],
|
||||||
|
"context": "Дополнительная информация",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Task Manifest
|
||||||
|
|
||||||
|
**Session:** {{ session_id }}
|
||||||
|
**Goal:** {{ goal }}
|
||||||
|
**Date:** {{ timestamp }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Overview
|
||||||
|
|
||||||
|
| ID | Title | Type | Depends On | Status |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| T1 | {{ title }} | {{ type }} | — | pending |
|
||||||
|
| T2 | {{ title }} | {{ type }} | T1 | pending |
|
||||||
|
|
||||||
|
**Total tasks:** {{ count }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Details
|
||||||
|
|
||||||
|
### T1: {{ title }}
|
||||||
|
|
||||||
|
**Type:** {{ type }}
|
||||||
|
**Description:** {{ description }}
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `{{ file_path }}`
|
||||||
|
|
||||||
|
**Depends on:** —
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] {{ criterion }}
|
||||||
|
- [ ] {{ criterion }}
|
||||||
|
|
||||||
|
**Context:** {{ context }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T2: {{ title }}
|
||||||
|
|
||||||
|
...
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.0.0
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# WORKFLOW — Сквозной пример сессии v3.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сценарий: рефакторинг auth-модуля
|
||||||
|
|
||||||
|
**Цель:** Вынести логику из `auth/login.py` (450 строк, монолит) в отдельные модули `auth/router.py`, `auth/schemas.py`, `auth/deps.py`.
|
||||||
|
|
||||||
|
**Целевой репозиторий:** `github.com/example/fastapi-app`
|
||||||
|
|
||||||
|
**Пользователь:** «Вынеси авторизацию в отдельные модули».
|
||||||
|
|
||||||
|
**MetaAgent:** v3.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PROJECT LOOP
|
||||||
|
|
||||||
|
#### INIT
|
||||||
|
|
||||||
|
Агент читает `AGENTS.md`, переходит в `.agent/src/GUIDE.md`. Понимает цикл. Создаёт `.agent/`, копирует исходники, инициализирует `checkpoints.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metaagent_version": "3.0.0",
|
||||||
|
"session_id": "ses_v30_001",
|
||||||
|
"target_repo": "/tmp/fastapi-app",
|
||||||
|
"goal": "Вынести авторизацию в auth/{router,schemas,deps}.py",
|
||||||
|
"project_type": null,
|
||||||
|
"phases": {
|
||||||
|
"init": "completed",
|
||||||
|
"analyse": "pending",
|
||||||
|
"roadmap": "pending",
|
||||||
|
"design": "pending",
|
||||||
|
"decomposition": "pending",
|
||||||
|
"execution": "pending",
|
||||||
|
"metastate": "pending",
|
||||||
|
"handoff": "pending"
|
||||||
|
},
|
||||||
|
"tasks": [],
|
||||||
|
"last_updated": "2026-10-08T15:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ANALYSE
|
||||||
|
|
||||||
|
Агент сканирует проект:
|
||||||
|
|
||||||
|
- Стек: Python 3.12, FastAPI, SQLAlchemy, pytest.
|
||||||
|
- `auth/login.py` — 450 строк, монолит (цель рефакторинга).
|
||||||
|
- Тесты: 48 passed (baseline).
|
||||||
|
|
||||||
|
Создаёт:
|
||||||
|
|
||||||
|
- `.agent/context/analysis-report.md`
|
||||||
|
- `.agent/context/project-state.md` (начальный)
|
||||||
|
|
||||||
|
`checkpoints.json`: `project_type = "existing"`, `phases.analyse = "completed"`.
|
||||||
|
|
||||||
|
#### ROADMAP
|
||||||
|
|
||||||
|
- `FUTURE/` — пусто.
|
||||||
|
- `.agent/decisions/` — пусто.
|
||||||
|
- Единственный источник — пользовательский запрос.
|
||||||
|
|
||||||
|
Создаёт `.agent/roadmap/sources.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## User Requests
|
||||||
|
| Вынести авторизацию | P0 | user:direct |
|
||||||
|
|
||||||
|
## Consolidated Priority Queue
|
||||||
|
1. Вынести auth/ (user:direct) — P0
|
||||||
|
```
|
||||||
|
|
||||||
|
`phases.roadmap = "completed"`.
|
||||||
|
|
||||||
|
#### DESIGN
|
||||||
|
|
||||||
|
**Пропускается** (existing-проект). `phases.design = "skipped"`.
|
||||||
|
|
||||||
|
#### DECOMPOSITION
|
||||||
|
|
||||||
|
Задачи:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "T1",
|
||||||
|
"title": "Создать auth/router.py",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"files": ["app/auth/router.py"],
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Роуты авторизации вынесены из auth/login.py",
|
||||||
|
"auth/router.py экспортирует router",
|
||||||
|
"Существующие тесты проходят"
|
||||||
|
],
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T2",
|
||||||
|
"title": "Создать auth/schemas.py",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"files": ["app/auth/schemas.py"],
|
||||||
|
"depends_on": ["T1"],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Pydantic схемы вынесены в auth/schemas.py",
|
||||||
|
"Существующие тесты проходят"
|
||||||
|
],
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T3",
|
||||||
|
"title": "Создать auth/deps.py",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"files": ["app/auth/deps.py"],
|
||||||
|
"depends_on": ["T1"],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Dependency injection функции вынесены в auth/deps.py",
|
||||||
|
"Существующие тесты проходят"
|
||||||
|
],
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`phases.decomposition = "completed"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WORK LOOP (первая итерация)
|
||||||
|
|
||||||
|
#### EXECUTION — задача T1
|
||||||
|
|
||||||
|
1. Берёт T1 (`pending`, нет зависимостей).
|
||||||
|
2. `status = "in_progress"`.
|
||||||
|
3. Создаёт `app/auth/router.py` — переносит роуты.
|
||||||
|
4. Тесты: 48/48.
|
||||||
|
5. Коммит: `abc1234 — refactor: extract auth router`.
|
||||||
|
6. Создаёт request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "req-T1",
|
||||||
|
"task_id": "T1",
|
||||||
|
"title": "Создать auth/router.py",
|
||||||
|
"status": "ready_for_review",
|
||||||
|
"goal": "Вынести роуты авторизации",
|
||||||
|
"changes": {
|
||||||
|
"summary": "Роуты авторизации вынесены из auth/login.py в auth/router.py",
|
||||||
|
"commits": ["abc1234"],
|
||||||
|
"files_changed": ["app/auth/router.py", "app/auth/__init__.py", "tests/test_auth_router.py"]
|
||||||
|
},
|
||||||
|
"verification": { "tests_passed": "48/48", "lsp_clean": true },
|
||||||
|
"fulfills_ac": ["Роуты вынесены", "Тесты проходят"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. `T1 → completed`.
|
||||||
|
|
||||||
|
#### EXECUTION — задача T2
|
||||||
|
|
||||||
|
Создаёт `auth/schemas.py`, request `req-T2`. T2 → completed.
|
||||||
|
|
||||||
|
#### EXECUTION — задача T3
|
||||||
|
|
||||||
|
Создаёт `auth/deps.py`, request `req-T3`. T3 → completed.
|
||||||
|
|
||||||
|
Задачи закончились. Агент ждёт команду.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### METASTATE (по команде пользователя)
|
||||||
|
|
||||||
|
**Пользователь:** «обнови метасостояние».
|
||||||
|
|
||||||
|
1. **Ревью requests:** три request-а, все approved.
|
||||||
|
- `req-T1`, `req-T2`, `req-T3` → `.agent/requests/archive/`.
|
||||||
|
|
||||||
|
2. **Архивация задач:**
|
||||||
|
- T1, T2, T3 → `.agent/archive/tasks/`.
|
||||||
|
- В `manifest.json` — one-liner: `status: "archived"`.
|
||||||
|
|
||||||
|
3. **Обновление project-state.md:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Key Modules
|
||||||
|
| Module | Status | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| app/auth/router.py | new | Вынесенные роуты |
|
||||||
|
| app/auth/schemas.py | new | Pydantic схемы |
|
||||||
|
| app/auth/deps.py | new | Dependency injection |
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Создание handoff-summary.md:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Session Summary
|
||||||
|
**Goal:** Рефакторинг авторизации
|
||||||
|
**Completed:** 3/3 tasks
|
||||||
|
**Approved requests:** req-T1, req-T2, req-T3
|
||||||
|
|
||||||
|
## Project State
|
||||||
|
auth разбит на router + schemas + deps.
|
||||||
|
Исходный auth/login.py: 450 → 120 строк.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- Проверить, не осталось ли прямых импортов из старого login.py
|
||||||
|
- Обновить main.py если нужно
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HANDOFF
|
||||||
|
|
||||||
|
```
|
||||||
|
HANDOFF COMPLETE
|
||||||
|
|
||||||
|
Session: ses_v30_001
|
||||||
|
Target: /tmp/fastapi-app
|
||||||
|
Type: existing
|
||||||
|
Tasks: 3/3 completed
|
||||||
|
|
||||||
|
Следующий агент начинает с .agent/handoff-summary.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Использование команд в процессе
|
||||||
|
|
||||||
|
В любой момент сессии пользователь мог вызвать:
|
||||||
|
|
||||||
|
- **«запиши это как ADR»** → `COMMANDS/adr.md` создал бы `.agent/decisions/001-modular-auth.md`.
|
||||||
|
- **«red team»** → `COMMANDS/red-team.md` создал бы `.agent/context/red-team-report.md` с попыткой сломать новую структуру.
|
||||||
|
- **«risk register»** → `COMMANDS/risk-register.md` зафиксировал бы допущения (например, «считаем, что порядок middleware не важен»).
|
||||||
|
|
||||||
|
Команды **не обязательны**. Если не вызваны — `.agent/decisions/`, `risk-register.md`, `red-team-report.md` не создаются.
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# MetaAgent — установка исходников в целевой проект
|
||||||
|
# Usage: .\install.ps1 [[-Path] target_path] [-Check] [-Update]
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Path = "",
|
||||||
|
[switch]$Check,
|
||||||
|
[switch]$Update,
|
||||||
|
[switch]$Help
|
||||||
|
)
|
||||||
|
|
||||||
|
$MetaAgentSrc = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
# --- helpers ---
|
||||||
|
function Write-Info { Write-Host " →" -NoNewline -ForegroundColor Blue; Write-Host " $args" }
|
||||||
|
function Write-Ok { Write-Host " ✓" -NoNewline -ForegroundColor Green; Write-Host " $args" }
|
||||||
|
function Write-Skip { Write-Host " −" -NoNewline -ForegroundColor Yellow; Write-Host " $args" }
|
||||||
|
function Write-Warn { Write-Host " ⚠" -NoNewline -ForegroundColor Yellow; Write-Host " $args" }
|
||||||
|
function Write-Fail { Write-Host " ✗" -NoNewline -ForegroundColor Red; Write-Host " $args" }
|
||||||
|
function Write-Header { param([string]$Label)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("─" * 40)
|
||||||
|
Write-Host " $Label"
|
||||||
|
Write-Host ("─" * 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Usage {
|
||||||
|
@"
|
||||||
|
Usage: install.ps1 [[-Path] target_path] [-Check] [-Update] [-Help]
|
||||||
|
|
||||||
|
Install MetaAgent sources into <target>/.agent/src/
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-Path Path to target project (default: interactive prompt)
|
||||||
|
-Check Dry-run: only check target readiness, no install
|
||||||
|
-Update Overwrite existing files in .agent/src/
|
||||||
|
-Help Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
.\install.ps1
|
||||||
|
.\install.ps1 -Path C:\Projects\MyApp
|
||||||
|
.\install.ps1 -Path C:\Projects\MyApp -Check
|
||||||
|
.\install.ps1 -Path C:\Projects\MyApp -Update
|
||||||
|
"@
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Help) { Show-Usage }
|
||||||
|
|
||||||
|
# --- resolve target ---
|
||||||
|
$TargetPath = $Path
|
||||||
|
if (-not $TargetPath) {
|
||||||
|
$TargetPath = Read-Host "Enter path to target project"
|
||||||
|
}
|
||||||
|
$TargetPath = $TargetPath.Trim()
|
||||||
|
|
||||||
|
# --- pre-flight -----------------------------------------------------------
|
||||||
|
Write-Header "Pre-flight"
|
||||||
|
|
||||||
|
# 1. target exists?
|
||||||
|
if (-not (Test-Path $TargetPath -PathType Container)) {
|
||||||
|
Write-Fail "Target directory '$TargetPath' does not exist."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$TargetPath = (Resolve-Path $TargetPath).Path
|
||||||
|
Write-Ok "Target: $TargetPath"
|
||||||
|
|
||||||
|
# 2. write permission? (try to create a temp file as probe)
|
||||||
|
$probe = [System.IO.Path]::Combine($TargetPath, ".metaagent_probe.tmp")
|
||||||
|
try {
|
||||||
|
[System.IO.File]::WriteAllBytes($probe, [byte[]]@())
|
||||||
|
Remove-Item $probe -Force
|
||||||
|
Write-Ok "Write permission: yes"
|
||||||
|
} catch {
|
||||||
|
Write-Fail "No write permission on '$TargetPath'."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. already installed? compare versions
|
||||||
|
$AgentDir = Join-Path $TargetPath ".agent"
|
||||||
|
$SrcDir = Join-Path $AgentDir "src"
|
||||||
|
$VersionFile = Join-Path $MetaAgentSrc "VERSION"
|
||||||
|
|
||||||
|
$Version = if (Test-Path $VersionFile -PathType Leaf) {
|
||||||
|
(Get-Content $VersionFile -Raw -Encoding UTF8).Trim()
|
||||||
|
} else { "?" }
|
||||||
|
|
||||||
|
$oldVerPath = Join-Path $SrcDir "VERSION"
|
||||||
|
if (Test-Path $oldVerPath -PathType Leaf) {
|
||||||
|
$oldVer = (Get-Content $oldVerPath -Raw -Encoding UTF8).Trim()
|
||||||
|
if ($oldVer -ne $Version) {
|
||||||
|
Write-Info "Existing MetaAgent v$oldVer found → upgrading to v$Version"
|
||||||
|
} else {
|
||||||
|
Write-Skip "MetaAgent v${Version} already installed (use -Update to reinstall)"
|
||||||
|
if (-not $Check) {
|
||||||
|
Write-Warn "No changes applied. Run with -Update to overwrite existing files."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Info "Fresh install: MetaAgent v$Version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. summary
|
||||||
|
$AgentsMd = Join-Path $TargetPath "AGENTS.md"
|
||||||
|
$RulesDir = Join-Path $AgentDir "rules"
|
||||||
|
$DecisionsDir = Join-Path $AgentDir "decisions"
|
||||||
|
$TasksDir = Join-Path $AgentDir "tasks"
|
||||||
|
$ContextDir = Join-Path $AgentDir "context"
|
||||||
|
$ArchiveDir = Join-Path $AgentDir "archive"
|
||||||
|
$RequestsDir = Join-Path $AgentDir "requests"
|
||||||
|
$RoadmapDir = Join-Path $AgentDir "roadmap"
|
||||||
|
$TempDir = Join-Path $TargetPath ".temp"
|
||||||
|
|
||||||
|
$DirList = @(
|
||||||
|
$SrcDir, $RulesDir, $DecisionsDir, $TasksDir,
|
||||||
|
(Join-Path $TasksDir "backlog"), $ContextDir,
|
||||||
|
$ArchiveDir,
|
||||||
|
(Join-Path $ArchiveDir "tasks"),
|
||||||
|
(Join-Path $ArchiveDir "decisions"),
|
||||||
|
(Join-Path $ArchiveDir "checkpoints"),
|
||||||
|
(Join-Path $RequestsDir "active"),
|
||||||
|
(Join-Path $RequestsDir "archive"),
|
||||||
|
$RoadmapDir,
|
||||||
|
(Join-Path $RoadmapDir "archive"),
|
||||||
|
$TempDir
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Check) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Info "--check mode: all checks passed, no changes applied."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- phase 1: directories ------------------------------------------------
|
||||||
|
Write-Header "Directories"
|
||||||
|
|
||||||
|
foreach ($d in $DirList) {
|
||||||
|
$null = New-Item -ItemType Directory -Path $d -Force
|
||||||
|
$short = $d.Replace("$TargetPath\", "")
|
||||||
|
if (Test-Path $d -PathType Container) {
|
||||||
|
Write-Ok $short
|
||||||
|
} else {
|
||||||
|
Write-Fail "$short (creation failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- phase 2: files ------------------------------------------------------
|
||||||
|
Write-Header "Files"
|
||||||
|
|
||||||
|
$copyCount = 0
|
||||||
|
$skipCount = 0
|
||||||
|
$failCount = 0
|
||||||
|
|
||||||
|
function Copy-File {
|
||||||
|
param([string]$Src, [string]$DstDir)
|
||||||
|
$name = Split-Path $Src -Leaf
|
||||||
|
$dst = Join-Path $DstDir $name
|
||||||
|
if (-not (Test-Path $Src -PathType Leaf)) {
|
||||||
|
Write-Skip "$name (source not found)"
|
||||||
|
$script:skipCount++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ($Update -or -not (Test-Path $dst)) {
|
||||||
|
try {
|
||||||
|
Copy-Item $Src $dst -Force -ErrorAction Stop
|
||||||
|
Write-Ok $name
|
||||||
|
$script:copyCount++
|
||||||
|
} catch {
|
||||||
|
Write-Fail $name
|
||||||
|
$script:failCount++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Skip "$name (exists, use -Update to overwrite)"
|
||||||
|
$script:skipCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Copy-Dir {
|
||||||
|
param([string]$Src, [string]$DstDir)
|
||||||
|
$name = Split-Path $Src -Leaf
|
||||||
|
$dst = Join-Path $DstDir $name
|
||||||
|
if (-not (Test-Path $Src -PathType Container)) {
|
||||||
|
Write-Skip "$name/ (source not found)"
|
||||||
|
$script:skipCount++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$null = New-Item -ItemType Directory -Path $dst -Force
|
||||||
|
try {
|
||||||
|
if ($Update) {
|
||||||
|
Get-ChildItem $Src | ForEach-Object {
|
||||||
|
Copy-Item $_.FullName $dst -Recurse -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Get-ChildItem $Src | ForEach-Object {
|
||||||
|
$targetPath = Join-Path $dst $_.Name
|
||||||
|
if (-not (Test-Path $targetPath)) {
|
||||||
|
Copy-Item $_.FullName $dst -Recurse -ErrorAction Stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Ok "$name/"
|
||||||
|
$script:copyCount++
|
||||||
|
} catch {
|
||||||
|
Write-Fail "$name/ (partial copy)"
|
||||||
|
$script:failCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-File (Join-Path $MetaAgentSrc "GUIDE.md") $SrcDir
|
||||||
|
Copy-File (Join-Path $MetaAgentSrc "BOUNDARIES.md") $SrcDir
|
||||||
|
Copy-File (Join-Path $MetaAgentSrc "CHANGELOG.md") $SrcDir
|
||||||
|
Copy-File (Join-Path $MetaAgentSrc "WORKFLOW.md") $SrcDir
|
||||||
|
Copy-File (Join-Path $MetaAgentSrc "VERSION") $SrcDir
|
||||||
|
Copy-Dir (Join-Path $MetaAgentSrc "PROTOCOLS") $SrcDir
|
||||||
|
Copy-Dir (Join-Path $MetaAgentSrc "COMMANDS") $SrcDir
|
||||||
|
Copy-Dir (Join-Path $MetaAgentSrc "TEMPLATES") $SrcDir
|
||||||
|
Copy-File (Join-Path $MetaAgentSrc "install.sh") $SrcDir
|
||||||
|
Copy-File (Join-Path $MetaAgentSrc "install.ps1") $SrcDir
|
||||||
|
|
||||||
|
# --- phase 3: AGENTS.md --------------------------------------------------
|
||||||
|
Write-Header "AGENTS.md"
|
||||||
|
|
||||||
|
if (-not (Test-Path $AgentsMd -PathType Leaf)) {
|
||||||
|
$content = @"
|
||||||
|
# MetaAgent
|
||||||
|
|
||||||
|
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v$Version —
|
||||||
|
набор инструкций для AI-агента.
|
||||||
|
|
||||||
|
## Контекст MetaAgent
|
||||||
|
|
||||||
|
| Ресурс | Путь |
|
||||||
|
|--------|------|
|
||||||
|
| Главная инструкция | `.agent/src/GUIDE.md` |
|
||||||
|
| Протоколы фаз | `.agent/src/PROTOCOLS/` |
|
||||||
|
| Команды (on-demand) | `.agent/src/COMMANDS/` |
|
||||||
|
| Шаблоны артефактов | `.agent/src/TEMPLATES/` |
|
||||||
|
| Границы (что разрешено/запрещено) | `.agent/src/BOUNDARIES.md` |
|
||||||
|
| История версий | `.agent/src/CHANGELOG.md` |
|
||||||
|
| Правила проекта | `.agent/rules/project-rules.md` |
|
||||||
|
| Пример работы | `.agent/src/WORKFLOW.md` |
|
||||||
|
| Версия | `.agent/src/VERSION` |
|
||||||
|
|
||||||
|
## Состояние сессии (если инициализировано)
|
||||||
|
|
||||||
|
| Артефакт | Путь |
|
||||||
|
|----------|------|
|
||||||
|
| Чекпоинты сессии | `.agent/checkpoints.json` |
|
||||||
|
| Слепок проекта | `.agent/context/project-state.md` |
|
||||||
|
| Анализ репозитория | `.agent/context/analysis-report.md` |
|
||||||
|
| Дорожная карта | `.agent/roadmap/sources.md` |
|
||||||
|
| Манифест задач | `.agent/tasks/manifest.json` |
|
||||||
|
| Сводка для следующего агента | `.agent/handoff-summary.md` |
|
||||||
|
| Сводка сессии | `.agent/session-summary.md` |
|
||||||
|
|
||||||
|
## Для агента
|
||||||
|
|
||||||
|
Жизненный цикл MetaAgent v$Version:
|
||||||
|
|
||||||
|
```
|
||||||
|
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Прочитай** `.agent/src/GUIDE.md` — пойми цикл и доступные команды.
|
||||||
|
2. **Прочитай** `.agent/src/BOUNDARIES.md` — соблюдай границы.
|
||||||
|
3. **Прочитай** `.agent/rules/project-rules.md` — выполни правила пользователя.
|
||||||
|
4. **Проверь** `.agent/checkpoints.json` — если существует, используй как состояние сессии.
|
||||||
|
5. **Проверь** `.agent/context/project-state.md` — получи актуальную картину.
|
||||||
|
6. **Проверь** `.agent/tasks/manifest.json` — если существует, выполняй задачи по порядку.
|
||||||
|
7. Если `.agent/` не инициализирован или устарел — запусти `install.sh --update` для
|
||||||
|
обновления исходников MetaAgent до актуальной версии.
|
||||||
|
|
||||||
|
## Команды (on-demand)
|
||||||
|
|
||||||
|
В любой момент пользователь может вызвать:
|
||||||
|
- `/adr` — записать архитектурное решение
|
||||||
|
- `/red-team` — попытаться сломать дизайн
|
||||||
|
- `/risk-register` — зафиксировать допущения
|
||||||
|
- `/alt-arch` — описать альтернативу
|
||||||
|
- `/invariant-tests` — тесты-инварианты для ADR
|
||||||
|
"@
|
||||||
|
$utf8 = [System.Text.Encoding]::UTF8
|
||||||
|
[System.IO.File]::WriteAllBytes($AgentsMd, $utf8.GetBytes($content))
|
||||||
|
Write-Ok "AGENTS.md created"
|
||||||
|
} elseif ($Update) {
|
||||||
|
$content = @"
|
||||||
|
# MetaAgent
|
||||||
|
|
||||||
|
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v$Version —
|
||||||
|
набор инструкций для AI-агента.
|
||||||
|
|
||||||
|
## Контекст MetaAgent
|
||||||
|
|
||||||
|
| Ресурс | Путь |
|
||||||
|
|--------|------|
|
||||||
|
| Главная инструкция | `.agent/src/GUIDE.md` |
|
||||||
|
| Протоколы фаз | `.agent/src/PROTOCOLS/` |
|
||||||
|
| Команды (on-demand) | `.agent/src/COMMANDS/` |
|
||||||
|
| Шаблоны артефактов | `.agent/src/TEMPLATES/` |
|
||||||
|
| Границы (что разрешено/запрещено) | `.agent/src/BOUNDARIES.md` |
|
||||||
|
| История версий | `.agent/src/CHANGELOG.md` |
|
||||||
|
| Правила проекта | `.agent/rules/project-rules.md` |
|
||||||
|
| Пример работы | `.agent/src/WORKFLOW.md` |
|
||||||
|
| Версия | `.agent/src/VERSION` |
|
||||||
|
|
||||||
|
## Состояние сессии (если инициализировано)
|
||||||
|
|
||||||
|
| Артефакт | Путь |
|
||||||
|
|----------|------|
|
||||||
|
| Чекпоинты сессии | `.agent/checkpoints.json` |
|
||||||
|
| Слепок проекта | `.agent/context/project-state.md` |
|
||||||
|
| Анализ репозитория | `.agent/context/analysis-report.md` |
|
||||||
|
| Дорожная карта | `.agent/roadmap/sources.md` |
|
||||||
|
| Манифест задач | `.agent/tasks/manifest.json` |
|
||||||
|
| Сводка для следующего агента | `.agent/handoff-summary.md` |
|
||||||
|
| Сводка сессии | `.agent/session-summary.md` |
|
||||||
|
|
||||||
|
## Для агента
|
||||||
|
|
||||||
|
Жизненный цикл MetaAgent v$Version:
|
||||||
|
|
||||||
|
```
|
||||||
|
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Прочитай** `.agent/src/GUIDE.md` — пойми цикл и доступные команды.
|
||||||
|
2. **Прочитай** `.agent/src/BOUNDARIES.md` — соблюдай границы.
|
||||||
|
3. **Прочитай** `.agent/rules/project-rules.md` — выполни правила пользователя.
|
||||||
|
4. **Проверь** `.agent/checkpoints.json` — если существует, используй как состояние сессии.
|
||||||
|
5. **Проверь** `.agent/context/project-state.md` — получи актуальную картину.
|
||||||
|
6. **Проверь** `.agent/tasks/manifest.json` — если существует, выполняй задачи по порядку.
|
||||||
|
7. Если `.agent/` не инициализирован или устарел — запусти `install.sh --update` для
|
||||||
|
обновления исходников MetaAgent до актуальной версии.
|
||||||
|
|
||||||
|
## Команды (on-demand)
|
||||||
|
|
||||||
|
В любой момент пользователь может вызвать:
|
||||||
|
- `/adr` — записать архитектурное решение
|
||||||
|
- `/red-team` — попытаться сломать дизайн
|
||||||
|
- `/risk-register` — зафиксировать допущения
|
||||||
|
- `/alt-arch` — описать альтернативу
|
||||||
|
- `/invariant-tests` — тесты-инварианты для ADR
|
||||||
|
"@
|
||||||
|
$utf8 = [System.Text.Encoding]::UTF8
|
||||||
|
[System.IO.File]::WriteAllBytes($AgentsMd, $utf8.GetBytes($content))
|
||||||
|
Write-Ok "AGENTS.md updated"
|
||||||
|
} else {
|
||||||
|
Write-Skip "AGENTS.md (exists, use -Update to overwrite)"
|
||||||
|
$script:skipCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- summary -------------------------------------------------------------
|
||||||
|
Write-Header "Summary"
|
||||||
|
Write-Host " MetaAgent v$Version → $SrcDir"
|
||||||
|
Write-Host ""
|
||||||
|
if ($copyCount -gt 0) { Write-Ok "$copyCount file(s) copied" }
|
||||||
|
if ($skipCount -gt 0) { Write-Skip "$skipCount file(s) skipped" }
|
||||||
|
if ($failCount -gt 0) { Write-Fail "$failCount file(s) failed" }
|
||||||
|
Write-Host ""
|
||||||
|
if ($failCount -eq 0) {
|
||||||
|
Write-Ok "Installation completed successfully."
|
||||||
|
} else {
|
||||||
|
Write-Fail "Installation completed with $failCount error(s)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# MetaAgent — установка исходников в целевой проект
|
||||||
|
# Usage: ./install.sh [--check|--update] [target_path]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
METAAGENT_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# --- helpers ---
|
||||||
|
red=; grn=; ylw=; blu=; rst=
|
||||||
|
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
|
||||||
|
red=$(tput setaf 1); grn=$(tput setaf 2)
|
||||||
|
ylw=$(tput setaf 3); blu=$(tput setaf 4)
|
||||||
|
rst=$(tput sgr0)
|
||||||
|
fi
|
||||||
|
info() { echo " ${blu}→${rst} $*"; }
|
||||||
|
ok() { echo " ${grn}✓${rst} $*"; }
|
||||||
|
skip() { echo " ${ylw}−${rst} $*"; }
|
||||||
|
warn() { echo " ${ylw}⚠${rst} $*"; }
|
||||||
|
fail() { echo " ${red}✗${rst} $*"; }
|
||||||
|
header(){ echo; echo "────────────────────────────────────────"; echo " $*"; echo "────────────────────────────────────────"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [--check|--update] [target_path]
|
||||||
|
|
||||||
|
Install MetaAgent sources into <target>/.agent/src/
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--check, -c Dry-run: only check target readiness, no install
|
||||||
|
--update, -u Overwrite existing files in .agent/src/
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$0
|
||||||
|
$0 /path/to/project
|
||||||
|
$0 --check /path/to/project
|
||||||
|
$0 --update /path/to/project
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- arg parsing ---
|
||||||
|
CHECK=false
|
||||||
|
UPDATE=false
|
||||||
|
TARGET_PATH=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--check|-c) CHECK=true; shift ;;
|
||||||
|
--update|-u) UPDATE=true; shift ;;
|
||||||
|
--help|-h) usage ;;
|
||||||
|
--*) echo "${red}Unknown option:${rst} $1"; usage ;;
|
||||||
|
*) TARGET_PATH="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- resolve target ---
|
||||||
|
if [[ -z "$TARGET_PATH" ]]; then
|
||||||
|
read -r -p "Enter path to target project: " TARGET_PATH
|
||||||
|
fi
|
||||||
|
TARGET_PATH="${TARGET_PATH/#\~/$HOME}"
|
||||||
|
|
||||||
|
# --- pre-flight -----------------------------------------------------------
|
||||||
|
header "Pre-flight"
|
||||||
|
|
||||||
|
# 1. target exists?
|
||||||
|
if [[ ! -d "$TARGET_PATH" ]]; then
|
||||||
|
fail "Target directory '$TARGET_PATH' does not exist."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# resolve to absolute path
|
||||||
|
TARGET_PATH="$(cd "$TARGET_PATH" 2>/dev/null && pwd)" || {
|
||||||
|
fail "Cannot access '$TARGET_PATH'."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
ok "Target: $TARGET_PATH"
|
||||||
|
|
||||||
|
# 2. write permission?
|
||||||
|
if [[ ! -w "$TARGET_PATH" ]]; then
|
||||||
|
fail "No write permission on '$TARGET_PATH'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Write permission: yes"
|
||||||
|
|
||||||
|
# 3. already installed? compare versions
|
||||||
|
AGENT_DIR="$TARGET_PATH/.agent"
|
||||||
|
SRC_DIR="$AGENT_DIR/src"
|
||||||
|
VERSION="$(cat "$METAAGENT_SRC/VERSION" 2>/dev/null || echo '?')"
|
||||||
|
|
||||||
|
if [[ -f "$SRC_DIR/VERSION" ]]; then
|
||||||
|
OLD_VER="$(cat "$SRC_DIR/VERSION" 2>/dev/null || echo '?')"
|
||||||
|
if [[ "$OLD_VER" != "$VERSION" ]]; then
|
||||||
|
info "Existing MetaAgent v${OLD_VER} found → upgrading to v${VERSION}"
|
||||||
|
else
|
||||||
|
skip "MetaAgent v${VERSION} already installed (use --update to reinstall)"
|
||||||
|
if [[ "$CHECK" == false ]]; then
|
||||||
|
warn "No changes applied. Run with --update to overwrite existing files."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Fresh install: MetaAgent v$VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. summary
|
||||||
|
AGENTS_MD="$TARGET_PATH/AGENTS.md"
|
||||||
|
RULES_DIR="$AGENT_DIR/rules"
|
||||||
|
DECISIONS_DIR="$AGENT_DIR/decisions"
|
||||||
|
TASKS_DIR="$AGENT_DIR/tasks"
|
||||||
|
CONTEXT_DIR="$AGENT_DIR/context"
|
||||||
|
ARCHIVE_DIR="$AGENT_DIR/archive"
|
||||||
|
ARCHIVE_TASKS_DIR="$ARCHIVE_DIR/tasks"
|
||||||
|
ARCHIVE_DECISIONS_DIR="$ARCHIVE_DIR/decisions"
|
||||||
|
ARCHIVE_CHECKPOINTS_DIR="$ARCHIVE_DIR/checkpoints"
|
||||||
|
REQUESTS_DIR="$AGENT_DIR/requests"
|
||||||
|
REQUESTS_ACTIVE_DIR="$REQUESTS_DIR/active"
|
||||||
|
REQUESTS_ARCHIVE_DIR="$REQUESTS_DIR/archive"
|
||||||
|
ROADMAP_DIR="$AGENT_DIR/roadmap"
|
||||||
|
ROADMAP_ARCHIVE_DIR="$ROADMAP_DIR/archive"
|
||||||
|
TEMP_DIR="$TARGET_PATH/.temp"
|
||||||
|
|
||||||
|
if [[ "$CHECK" == true ]]; then
|
||||||
|
echo ""
|
||||||
|
info "${ylw}--check mode:${rst} all checks passed, no changes applied."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- phase 1: directories ------------------------------------------------
|
||||||
|
header "Directories"
|
||||||
|
|
||||||
|
mkdir -p "$SRC_DIR" "$RULES_DIR" "$DECISIONS_DIR" "$TASKS_DIR" "$TASKS_DIR/backlog" \
|
||||||
|
"$CONTEXT_DIR" "$ARCHIVE_DIR" "$ARCHIVE_TASKS_DIR" "$ARCHIVE_DECISIONS_DIR" \
|
||||||
|
"$ARCHIVE_CHECKPOINTS_DIR" \
|
||||||
|
"$REQUESTS_ACTIVE_DIR" "$REQUESTS_ARCHIVE_DIR" \
|
||||||
|
"$ROADMAP_DIR" "$ROADMAP_ARCHIVE_DIR" \
|
||||||
|
"$TEMP_DIR"
|
||||||
|
|
||||||
|
for d in "$SRC_DIR" "$RULES_DIR" "$DECISIONS_DIR" "$TASKS_DIR" "$TASKS_DIR/backlog" \
|
||||||
|
"$CONTEXT_DIR" "$ARCHIVE_DIR" "$ARCHIVE_TASKS_DIR" "$ARCHIVE_DECISIONS_DIR" \
|
||||||
|
"$ARCHIVE_CHECKPOINTS_DIR" \
|
||||||
|
"$REQUESTS_ACTIVE_DIR" "$REQUESTS_ARCHIVE_DIR" \
|
||||||
|
"$ROADMAP_DIR" "$ROADMAP_ARCHIVE_DIR" \
|
||||||
|
"$TEMP_DIR"; do
|
||||||
|
short="${d#$TARGET_PATH/}"
|
||||||
|
if [[ -d "$d" ]]; then
|
||||||
|
ok "$short"
|
||||||
|
else
|
||||||
|
fail "$short (creation failed)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- phase 2: files ------------------------------------------------------
|
||||||
|
header "Files"
|
||||||
|
|
||||||
|
COPY_COUNT=0
|
||||||
|
SKIP_COUNT=0
|
||||||
|
FAIL_COUNT=0
|
||||||
|
|
||||||
|
copy_file() {
|
||||||
|
local src="$1" dst_dir="$2"
|
||||||
|
local name; name="$(basename "$src")"
|
||||||
|
local dst="$dst_dir/$name"
|
||||||
|
if [[ ! -f "$src" ]]; then
|
||||||
|
skip "$name (source not found)"
|
||||||
|
SKIP_COUNT=$((SKIP_COUNT + 1))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ "$UPDATE" == true ]] || [[ ! -f "$dst" ]]; then
|
||||||
|
if cp "$src" "$dst"; then
|
||||||
|
ok "$name"
|
||||||
|
COPY_COUNT=$((COPY_COUNT + 1))
|
||||||
|
else
|
||||||
|
fail "$name"
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "$name (exists, use --update to overwrite)"
|
||||||
|
SKIP_COUNT=$((SKIP_COUNT + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_dir() {
|
||||||
|
local src="$1" dst_dir="$2"
|
||||||
|
local name; name="$(basename "$src")"
|
||||||
|
local dst="$dst_dir/$name"
|
||||||
|
if [[ ! -d "$src" ]]; then
|
||||||
|
skip "$name/ (source not found)"
|
||||||
|
SKIP_COUNT=$((SKIP_COUNT + 1))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
mkdir -p "$dst"
|
||||||
|
if [[ "$UPDATE" == true ]]; then
|
||||||
|
if cp -rf "$src"/* "$dst/" 2>/dev/null; then
|
||||||
|
ok "$name/"
|
||||||
|
COPY_COUNT=$((COPY_COUNT + 1))
|
||||||
|
else
|
||||||
|
fail "$name/ (partial copy)"
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
cp -rn "$src"/* "$dst/" 2>/dev/null || true
|
||||||
|
ok "$name/"
|
||||||
|
COPY_COUNT=$((COPY_COUNT + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_file "$METAAGENT_SRC/GUIDE.md" "$SRC_DIR"
|
||||||
|
copy_file "$METAAGENT_SRC/BOUNDARIES.md" "$SRC_DIR"
|
||||||
|
copy_file "$METAAGENT_SRC/CHANGELOG.md" "$SRC_DIR"
|
||||||
|
copy_file "$METAAGENT_SRC/WORKFLOW.md" "$SRC_DIR"
|
||||||
|
copy_file "$METAAGENT_SRC/VERSION" "$SRC_DIR"
|
||||||
|
copy_dir "$METAAGENT_SRC/PROTOCOLS" "$SRC_DIR"
|
||||||
|
copy_dir "$METAAGENT_SRC/COMMANDS" "$SRC_DIR"
|
||||||
|
copy_dir "$METAAGENT_SRC/TEMPLATES" "$SRC_DIR"
|
||||||
|
copy_file "$METAAGENT_SRC/install.sh" "$SRC_DIR"
|
||||||
|
copy_file "$METAAGENT_SRC/install.ps1" "$SRC_DIR"
|
||||||
|
|
||||||
|
# --- phase 3: AGENTS.md --------------------------------------------------
|
||||||
|
header "AGENTS.md"
|
||||||
|
|
||||||
|
create_agents_md() {
|
||||||
|
cat > "$1" << AGENTS_EOF
|
||||||
|
# MetaAgent
|
||||||
|
|
||||||
|
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v$VERSION —
|
||||||
|
набор инструкций для AI-агента.
|
||||||
|
|
||||||
|
## Контекст MetaAgent
|
||||||
|
|
||||||
|
| Ресурс | Путь |
|
||||||
|
|--------|------|
|
||||||
|
| Главная инструкция | \`.agent/src/GUIDE.md\` |
|
||||||
|
| Протоколы фаз | \`.agent/src/PROTOCOLS/\` |
|
||||||
|
| Команды (on-demand) | \`.agent/src/COMMANDS/\` |
|
||||||
|
| Шаблоны артефактов | \`.agent/src/TEMPLATES/\` |
|
||||||
|
| Границы (что разрешено/запрещено) | \`.agent/src/BOUNDARIES.md\` |
|
||||||
|
| История версий | \`.agent/src/CHANGELOG.md\` |
|
||||||
|
| Правила проекта | \`.agent/rules/project-rules.md\` |
|
||||||
|
| Пример работы | \`.agent/src/WORKFLOW.md\` |
|
||||||
|
| Версия | \`.agent/src/VERSION\` |
|
||||||
|
|
||||||
|
## Состояние сессии (если инициализировано)
|
||||||
|
|
||||||
|
| Артефакт | Путь |
|
||||||
|
|----------|------|
|
||||||
|
| Чекпоинты сессии | \`.agent/checkpoints.json\` |
|
||||||
|
| Слепок проекта | \`.agent/context/project-state.md\` |
|
||||||
|
| Анализ репозитория | \`.agent/context/analysis-report.md\` |
|
||||||
|
| Дорожная карта | \`.agent/roadmap/sources.md\` |
|
||||||
|
| Манифест задач | \`.agent/tasks/manifest.json\` |
|
||||||
|
| Сводка для следующего агента | \`.agent/handoff-summary.md\` |
|
||||||
|
| Сводка сессии | \`.agent/session-summary.md\` |
|
||||||
|
|
||||||
|
## Для агента
|
||||||
|
|
||||||
|
Жизненный цикл MetaAgent v$VERSION:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
1. **Прочитай** \`.agent/src/GUIDE.md\` — пойми цикл и доступные команды.
|
||||||
|
2. **Прочитай** \`.agent/src/BOUNDARIES.md\` — соблюдай границы.
|
||||||
|
3. **Прочитай** \`.agent/rules/project-rules.md\` — выполни правила пользователя.
|
||||||
|
4. **Проверь** \`.agent/checkpoints.json\` — если существует, используй как состояние сессии.
|
||||||
|
5. **Проверь** \`.agent/context/project-state.md\` — получи актуальную картину.
|
||||||
|
6. **Проверь** \`.agent/tasks/manifest.json\` — если существует, выполняй задачи по порядку.
|
||||||
|
7. Если \`.agent/\` не инициализирован или устарел — запусти \`install.sh --update\` для
|
||||||
|
обновления исходников MetaAgent до актуальной версии.
|
||||||
|
|
||||||
|
## Команды (on-demand)
|
||||||
|
|
||||||
|
В любой момент пользователь может вызвать:
|
||||||
|
- \`/adr\` — записать архитектурное решение
|
||||||
|
- \`/red-team\` — попытаться сломать дизайн
|
||||||
|
- \`/risk-register\` — зафиксировать допущения
|
||||||
|
- \`/alt-arch\` — описать альтернативу
|
||||||
|
- \`/invariant-tests\` — тесты-инварианты для ADR
|
||||||
|
AGENTS_EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ! -f "$AGENTS_MD" ]]; then
|
||||||
|
create_agents_md "$AGENTS_MD"
|
||||||
|
ok "AGENTS.md created"
|
||||||
|
elif [[ "$UPDATE" == true ]]; then
|
||||||
|
create_agents_md "$AGENTS_MD"
|
||||||
|
ok "AGENTS.md updated"
|
||||||
|
else
|
||||||
|
skip "AGENTS.md (exists, use --update to overwrite)"
|
||||||
|
SKIP_COUNT=$((SKIP_COUNT + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- summary -------------------------------------------------------------
|
||||||
|
header "Summary"
|
||||||
|
echo " MetaAgent v$VERSION → $SRC_DIR"
|
||||||
|
echo ""
|
||||||
|
if (( COPY_COUNT > 0 )); then
|
||||||
|
ok "${COPY_COUNT} file(s) copied"
|
||||||
|
fi
|
||||||
|
if (( SKIP_COUNT > 0 )); then
|
||||||
|
skip "${SKIP_COUNT} file(s) skipped"
|
||||||
|
fi
|
||||||
|
if (( FAIL_COUNT > 0 )); then
|
||||||
|
fail "${FAIL_COUNT} file(s) failed"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
if (( FAIL_COUNT == 0 )); then
|
||||||
|
ok "Installation completed successfully."
|
||||||
|
else
|
||||||
|
fail "Installation completed with ${FAIL_COUNT} error(s)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
{
|
||||||
|
"metaagent_version": "3.0.0",
|
||||||
|
"session_id": "metaagent-init-2026-10-09",
|
||||||
|
"target_repo": "S:/Git/nixos",
|
||||||
|
"goal": "Установить metaagent, перенести накопленные данные (AGENTS.md, docs/arch/*) в структуру .agent/.",
|
||||||
|
"project_type": "existing",
|
||||||
|
"date": "2026-10-09T20:30",
|
||||||
|
"phases": {
|
||||||
|
"init": "completed",
|
||||||
|
"analyse": "pending",
|
||||||
|
"roadmap": "pending",
|
||||||
|
"design": "skipped",
|
||||||
|
"decomposition": "pending",
|
||||||
|
"execution": "pending",
|
||||||
|
"metastate": "pending",
|
||||||
|
"handoff": "pending"
|
||||||
|
},
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "T1",
|
||||||
|
"title": "A1: mobile.nix импортирует несуществующий lib/xlib.nix",
|
||||||
|
"type": "fix",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"nix flake check проходит на .#nixOnDroidConfigurations.epral",
|
||||||
|
"configurations/mobile.nix:12 использует import ../lib/xlib"
|
||||||
|
],
|
||||||
|
"files": ["configurations/mobile.nix"],
|
||||||
|
"blocks": ["T15", "T16"],
|
||||||
|
"notes": "Правка как в configurations/default.nix:5. До правки epral мертв."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T2",
|
||||||
|
"title": "A2: убедиться, что nix flake check вообще запускается",
|
||||||
|
"type": "verify",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": ["T1"],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"nix flake check зелёный (после T1)",
|
||||||
|
"checks в deploy покрывают всё дерево outputs"
|
||||||
|
],
|
||||||
|
"files": ["deploy/default.nix"],
|
||||||
|
"blocks": ["T15"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T3",
|
||||||
|
"title": "A3: явная финальная политика nftables на VDS",
|
||||||
|
"type": "fix",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"nft list ruleset на otreca показывает явное последнее правило или policy",
|
||||||
|
"firewall.* либо выключен, либо синхронизирован с nftables (не оба сразу)",
|
||||||
|
"ssh с внешнего адреса работает"
|
||||||
|
],
|
||||||
|
"files": ["configurations/vds.nix"],
|
||||||
|
"blocks": ["T11", "T12"],
|
||||||
|
"notes": "Сначала диагностика: nft list ruleset, systemctl status nftables firewall-nftables. Политика — белый список (предпочтительно) или мягкий вариант с явным финальным правилом."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T4",
|
||||||
|
"title": "B1: guard на несмонтированный носитель /mnt/services",
|
||||||
|
"type": "security",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"В xlib/helpers.nix есть mkStorageGuard",
|
||||||
|
"postgresql, samba, homebox, gitea, navidrome, syncthing, uptime-kuma, immich, nextcloud, calibre-web, 3x-ui, tape-rotation используют mkStorageGuard",
|
||||||
|
"Имитация отказа: umount /mnt/services + systemctl start postgresql → FAIL, а не пустая база",
|
||||||
|
"В AGENTS.md добавлена строка про findmnt перед рестартом (уже в R4)"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"lib/xlib/helpers.nix",
|
||||||
|
"modules/server/postgresql.nix",
|
||||||
|
"modules/server/samba.nix",
|
||||||
|
"modules/server/homebox.nix",
|
||||||
|
"modules/server/gitea.nix",
|
||||||
|
"modules/server/navidrome.nix",
|
||||||
|
"modules/server/syncthing.nix",
|
||||||
|
"modules/server/uptime-kuma.nix",
|
||||||
|
"modules/server/immich.nix",
|
||||||
|
"modules/server/nextcloud.nix",
|
||||||
|
"modules/server/calibre-web.nix",
|
||||||
|
"modules/containers/3x-ui.nix",
|
||||||
|
"modules/containers/tape-rotation.nix"
|
||||||
|
],
|
||||||
|
"blocks": [],
|
||||||
|
"notes": "ConditionPathIsMountPoint=/mnt/services НЕ использовать (bind-mount внутри одной ФС не меняет st_dev). Надёжны requiresMountsFor или ConditionPathIsMountPoint на xlib.dirs.server-home."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T5",
|
||||||
|
"title": "B2: зафиксировать, что бэкапов в конфигурации нет",
|
||||||
|
"type": "documentation",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"В project-rules.md (R1.x) явно сказано, что бэкапы вне Nix",
|
||||||
|
"В project-state.md Open Concerns указано, что бэкапы — внешние"
|
||||||
|
],
|
||||||
|
"files": [".agent/rules/project-rules.md", ".agent/context/project-state.md"],
|
||||||
|
"blocks": [],
|
||||||
|
"notes": "Ждёт ответа 5.6 — где бэкапы и как проверять. Не код, а запись."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T6",
|
||||||
|
"title": "C1: вернуть расследование 3x-ui, потерянное при откате",
|
||||||
|
"type": "documentation",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"git show 9974784:modules/containers/3x-ui-migration-notes.md > .agent/decisions/notes/3x-ui-xray-26.9.md (или аналогичный путь)",
|
||||||
|
"Файл дополнен вердиктом: миграция 26.7 → 26.9 провалена, откат осознанный, причина — X25519MLKEM768"
|
||||||
|
],
|
||||||
|
"files": [".agent/decisions/notes/3x-ui-xray-26.9.md"],
|
||||||
|
"blocks": [],
|
||||||
|
"notes": "200 строк удалены в 22a19be. git show 9974784:... — восстановить."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T7",
|
||||||
|
"title": "C2: зафиксировать фактические версии панели и ядра 3x-ui",
|
||||||
|
"type": "investigation",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"podman images ... | grep 3x-ui — записано",
|
||||||
|
"podman inspect ghcr.io/mhsanaei/3x-ui — RepoDigests записано",
|
||||||
|
"podman exec 3xui_app /app/bin/xray-linux-amd64 version — записано",
|
||||||
|
"В modules/containers/3x-ui.nix:54 :latest заменён на конкретный тег/digest"
|
||||||
|
],
|
||||||
|
"files": ["modules/containers/3x-ui.nix"],
|
||||||
|
"blocks": ["T8"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T8",
|
||||||
|
"title": "C3: убрать сервис автообновления 3x-ui",
|
||||||
|
"type": "fix",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"podman-update-3xui_app удалён из modules/containers/3x-ui.nix",
|
||||||
|
"Закомментированный таймер (строки 97-103) удалён",
|
||||||
|
"Оставлен комментарий-предупреждение"
|
||||||
|
],
|
||||||
|
"files": ["modules/containers/3x-ui.nix"],
|
||||||
|
"blocks": [],
|
||||||
|
"notes": "Обновление панели через pull = путь, по которому в 2026-10-04 декларация разошлась с рантаймом. Автоматизировать нельзя."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T9",
|
||||||
|
"title": "C4: записать в project-rules, что ядро Xray — состояние панели, а не Nix",
|
||||||
|
"type": "documentation",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"В project-rules.md (R1.x) явно сказано: версия ядра Xray выбирается в UI панели и лежит в её sqlite-БД, то есть вне Nix",
|
||||||
|
"Перед деплоем/рестартом 3x-ui проверять версию ядра в панели"
|
||||||
|
],
|
||||||
|
"files": [".agent/rules/project-rules.md"],
|
||||||
|
"blocks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T10",
|
||||||
|
"title": "C5: решить судьбу reality443Forwarding",
|
||||||
|
"type": "decision",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Решение: (а) оставить + описать в инвариантах, или (б) погасить опцию в vds/default.nix + убрать из options.nix, или (в) довести до рабочего состояния",
|
||||||
|
"Решение зафиксировано в .agent/decisions/"
|
||||||
|
],
|
||||||
|
"files": ["modules/vds/default.nix", "modules/options.nix", "modules/containers/3x-ui.nix"],
|
||||||
|
"blocks": [],
|
||||||
|
"notes": "Связано с 6.9."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T11",
|
||||||
|
"title": "D1: пробросы роутера — главный недостающий инвариант",
|
||||||
|
"type": "documentation",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": ["T3"],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"В project-rules.md (R1.3) формулировка: «Экспозиция наружу определяется пробросами на роутере, не openFirewall. На sapphira networking.firewall.enable = false намеренно. Список пробросов: 22, 80, 443, 8443 (3x-ui/Xray REALITY), 22000 (syncthing). Новый сервис не становится доступен из интернета, пока не добавлен проброс.» (уже сделано)",
|
||||||
|
"В project-state.md Network секция содержит список пробросов"
|
||||||
|
],
|
||||||
|
"files": [".agent/rules/project-rules.md", ".agent/context/project-state.md"],
|
||||||
|
"blocks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T12",
|
||||||
|
"title": "D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira",
|
||||||
|
"type": "documentation",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"В project-rules.md (R1.4) инвариант сформулирован (уже сделано)",
|
||||||
|
"Список из 4 мест, которые придётся править при смене: modules/server/nginx.nix, modules/server/nextcloud.nix, modules/vds/systemd.nix, modules/vds/nginx.nix"
|
||||||
|
],
|
||||||
|
"files": [".agent/rules/project-rules.md"],
|
||||||
|
"blocks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T13",
|
||||||
|
"title": "D3: убрать мёртвое правило firewall на sapphira",
|
||||||
|
"type": "fix",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": ["T11"],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"modules/server/nginx.nix:225-228 (allowedTCPPorts = [80 443]) удалено или помечено комментарием «депенит от D1»"
|
||||||
|
],
|
||||||
|
"files": ["modules/server/nginx.nix"],
|
||||||
|
"blocks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T14",
|
||||||
|
"title": "E1: написать AGENTS.md в корне (с metaagent-шапкой)",
|
||||||
|
"type": "documentation",
|
||||||
|
"status": "completed",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": [],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"AGENTS.md содержит: yaml frontmatter (для Obsidian dataview), metaagent-указатель, краткую выжимку nixos (хосты, инварианты, ловушки, куда лезть, проверки, где не лезть)"
|
||||||
|
],
|
||||||
|
"files": ["AGENTS.md"],
|
||||||
|
"blocks": [],
|
||||||
|
"notes": "Сделано в этой инициализации (см. объединённый AGENTS.md)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T15",
|
||||||
|
"title": "E2: выбрать проверки, которые заменят половину инвариантов",
|
||||||
|
"type": "decision",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": ["T2"],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Список из 7 кандидатов (см. analysis-report.md §5) отфильтрован владельцем",
|
||||||
|
"Выбранные проверки превращены в CI или git pre-commit hook"
|
||||||
|
],
|
||||||
|
"files": [],
|
||||||
|
"blocks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "T16",
|
||||||
|
"title": "E3: судьба 15 закомментированных модулей в modules/server/default.nix:33-47",
|
||||||
|
"type": "refactor",
|
||||||
|
"status": "pending",
|
||||||
|
"origin": "user:direct",
|
||||||
|
"depends_on": ["T1"],
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Решение: удалить или оставить как референс",
|
||||||
|
"Если удалять — то 15 модулей (remnawave, coturn, mealie, memos, minecraft, n8n, netdata, nfs, open-webui, rsync, step-ca, stirling-pdf, transmission, trilium, zerotier) перенесены в archive или удалены"
|
||||||
|
],
|
||||||
|
"files": ["modules/server/default.nix"],
|
||||||
|
"blocks": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"backlog_count": 23,
|
||||||
|
"backlog_note": "Открытые вопросы из roadmap/sources.md (F: 2.2, 2.5, 2.6, 3.2, 4.1, 4.2, 4.3, 4.4, 4.5, 5.1, 5.3, 5.4, 5.5, 5.6, 6.6, 6.7, 6.8, 6.9, 7.4, 8.2, 8.4, 8.5, 8.6) ждут ответа владельца и станут задачами после ответа."
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Task Manifest
|
||||||
|
|
||||||
|
**Session:** `metaagent-init-2026-10-09`
|
||||||
|
**Goal:** Установить metaagent, перенести накопленные данные (AGENTS.md, docs/arch/*) в структуру `.agent/`.
|
||||||
|
**Date:** 2026-10-09T20:30
|
||||||
|
**Project type:** `existing`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Overview
|
||||||
|
|
||||||
|
| ID | Title | Type | Depends On | Status | Origin |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| T1 | A1: mobile.nix импортирует несуществующий lib/xlib.nix | fix | — | pending | user:direct |
|
||||||
|
| T2 | A2: убедиться, что nix flake check вообще запускается | verify | T1 | pending | user:direct |
|
||||||
|
| T3 | A3: явная финальная политика nftables на VDS | fix | — | pending | user:direct |
|
||||||
|
| T4 | B1: guard на несмонтированный носитель /mnt/services | security | — | pending | user:direct |
|
||||||
|
| T5 | B2: зафиксировать, что бэкапов в конфигурации нет | docs | — | pending | user:direct |
|
||||||
|
| T6 | C1: вернуть расследование 3x-ui, потерянное при откате | docs | — | pending | user:direct |
|
||||||
|
| T7 | C2: зафиксировать фактические версии панели и ядра 3x-ui | investigate | — | pending | user:direct |
|
||||||
|
| T8 | C3: убрать сервис автообновления 3x-ui | fix | — | pending | user:direct |
|
||||||
|
| T9 | C4: записать, что ядро Xray — состояние панели, а не Nix | docs | — | pending | user:direct |
|
||||||
|
| T10 | C5: решить судьбу reality443Forwarding | decision | — | pending | user:direct |
|
||||||
|
| T11 | D1: пробросы роутера — главный недостающий инвариант | docs | T3 | pending | user:direct |
|
||||||
|
| T12 | D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira | docs | — | pending | user:direct |
|
||||||
|
| T13 | D3: убрать мёртвое правило firewall на sapphira | fix | T11 | pending | user:direct |
|
||||||
|
| T14 | E1: написать AGENTS.md в корне (с metaagent-шапкой) | docs | — | **completed** | user:direct |
|
||||||
|
| T15 | E2: выбрать проверки, которые заменят половину инвариантов | decision | T2 | pending | user:direct |
|
||||||
|
| T16 | E3: судьба 15 закомментированных модулей | refactor | T1 | pending | user:direct |
|
||||||
|
|
||||||
|
**Total tasks:** 16
|
||||||
|
**Pending:** 15
|
||||||
|
**In progress:** 0
|
||||||
|
**Completed:** 1
|
||||||
|
**Backlog (ждут ответа):** 23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задачи по группам
|
||||||
|
|
||||||
|
### A. Блокеры (T1–T3)
|
||||||
|
|
||||||
|
- **T1 (A1)** — критично: до правки `epral` мёртв. Цена правки: одна строка в `mobile.nix:12`.
|
||||||
|
- **T2 (A2)** — диагностика: ловит ли `flake check` проблему из T1.
|
||||||
|
- **T3 (A3)** — опасная зона: править `nftables` без `nft list ruleset` на otreca = риск отрезать SSH.
|
||||||
|
|
||||||
|
### B. Защита данных (T4–T5)
|
||||||
|
|
||||||
|
- **T4 (B1)** — 12+ сервисов на пустой БД после рестарта без диска. **Самый крупный фикс** (правка `mkStorageGuard` + 12 потребителей).
|
||||||
|
- **T5 (B2)** — запись в project-rules, не код. Ждёт ответа 5.6.
|
||||||
|
|
||||||
|
### C. 3x-ui: заморозить рабочее состояние (T6–T10)
|
||||||
|
|
||||||
|
- **T6 (C1)** — восстановить 200 строк из коммита `9974784` + дописать вердикт.
|
||||||
|
- **T7 (C2)** — сначала диагностика на sapphira и otreca, потом запинить тег.
|
||||||
|
- **T8 (C3)** — удалить `podman-update-3xui_app` + закомментированный таймер.
|
||||||
|
- **T9 (C4)** — запись в project-rules (R1.x).
|
||||||
|
- **T10 (C5)** — связано с вопросом 6.9.
|
||||||
|
|
||||||
|
### D. Сетевая граница (T11–T13)
|
||||||
|
|
||||||
|
- **T11 (D1)** — запись в project-rules (R1.3) + project-state.md.
|
||||||
|
- **T12 (D2)** — запись в project-rules (R1.4).
|
||||||
|
- **T13 (D3)** — мелкая чистка мёртвого правила.
|
||||||
|
|
||||||
|
### E. Документация (T14–T16)
|
||||||
|
|
||||||
|
- **T14 (E1)** — **выполнено** в этой инициализации.
|
||||||
|
- **T15 (E2)** — выбор из 7 кандидатов на CI-проверки (см. `analysis-report.md §5`).
|
||||||
|
- **T16 (E3)** — рефакторинг 15 модулей.
|
||||||
|
|
||||||
|
### F. Backlog (ждут ответа)
|
||||||
|
|
||||||
|
23 вопроса из `roadmap/sources.md` (F: 2.2, 2.5, 2.6, 3.2, 4.1, 4.2, 4.3, 4.4, 4.5, 5.1, 5.3, 5.4, 5.5, 5.6, 6.6, 6.7, 6.8, 6.9, 7.4, 8.2, 8.4, 8.5, 8.6) — становятся задачами после ответа владельца.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
```
|
||||||
|
T1 → T2 → T15
|
||||||
|
T1 → T16
|
||||||
|
T3 → T11 → T13
|
||||||
|
```
|
||||||
|
|
||||||
|
Остальные задачи можно делать параллельно.
|
||||||
+4
-1
@@ -1,2 +1,5 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.omo
|
.omo
|
||||||
|
__pycache__
|
||||||
|
scripts
|
||||||
|
.temp
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
aliases: []
|
||||||
|
cssclasses:
|
||||||
|
date-created: 2026-10-09T19:01
|
||||||
|
date-modified: 2026-10-09T20:30
|
||||||
|
tags: [metaagent, nixos, agents]
|
||||||
|
---
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v3.0.0 — набор
|
||||||
|
инструкций для AI-агента.
|
||||||
|
|
||||||
|
> NixOS-конфиг домашнего флота. 6 NixOS-хостов + Android (`nix-on-droid`).
|
||||||
|
> Этот файл — то, что агент должен прочитать **до** первого изменения. Если
|
||||||
|
> задача выглядит так, что требует сломать что-то из «Подтверждённых
|
||||||
|
> инвариантов» или «Ловушек» ниже — остановиться и спросить.
|
||||||
|
|
||||||
|
## Контекст MetaAgent
|
||||||
|
|
||||||
|
| Ресурс | Путь |
|
||||||
|
|--------|------|
|
||||||
|
| Главная инструкция | `.agent/src/GUIDE.md` |
|
||||||
|
| Протоколы фаз | `.agent/src/PROTOCOLS/` |
|
||||||
|
| Команды (on-demand) | `.agent/src/COMMANDS/` |
|
||||||
|
| Шаблоны артефактов | `.agent/src/TEMPLATES/` |
|
||||||
|
| Границы (что разрешено/запрещено) | `.agent/src/BOUNDARIES.md` |
|
||||||
|
| История версий | `.agent/src/CHANGELOG.md` |
|
||||||
|
| Правила проекта (полные) | `.agent/rules/project-rules.md` |
|
||||||
|
| Слепок проекта | `.agent/context/project-state.md` |
|
||||||
|
| Анализ репозитория | `.agent/context/analysis-report.md` |
|
||||||
|
| Дорожная карта | `.agent/roadmap/sources.md` |
|
||||||
|
| Манифест задач | `.agent/tasks/manifest.json` |
|
||||||
|
| ADR (архитектурные решения) | `.agent/decisions/` |
|
||||||
|
| Пример работы | `.agent/src/WORKFLOW.md` |
|
||||||
|
| Версия | `.agent/src/VERSION` |
|
||||||
|
|
||||||
|
## Архитектура (30 секунд)
|
||||||
|
|
||||||
|
```
|
||||||
|
flake.nix
|
||||||
|
├── configurations/ ← реестр хостов (1 запись = 1 машина)
|
||||||
|
├── modules/ ← essentials + per-type (desktop/server/vds/wsl/containers/termux)
|
||||||
|
├── home/ ← home-manager (per device-type)
|
||||||
|
├── lib/xlib/ ← чистые данные: devices, dirs, helpers
|
||||||
|
├── deploy/, secrets/ (sops), overlays/, pkgs/
|
||||||
|
└── .agent/ ← MetaAgent state (rules, decisions, tasks, context, requests, roadmap)
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробная карта: `.agent/context/project-state.md` и `.agent/context/analysis-report.md`.
|
||||||
|
|
||||||
|
## Хосты
|
||||||
|
|
||||||
|
| Attr / имя | device.type | Роль | Деплой | stateVersion | Примечание |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `default` (nixos) | minimal | Шаблон / минималка | — | — | hostname `"nixos"` |
|
||||||
|
| `atoridu` | primary | Основной десктоп | — (manual) | 26.05 | xanmod, mini-PC |
|
||||||
|
| `rydiwo` | secondary | Chuwi MiniBook | deploy-rs | 26.05 | xanmod, NTFS `lamet-drive` |
|
||||||
|
| `otrecа` | vds | VPS | deploy-rs | 25.05 | Tailscale-only SSH, nftables (см. T3) |
|
||||||
|
| `sapphira` | server | Домашний сервер | deploy-rs | 25.05 | `firewall.enable = false` намеренно |
|
||||||
|
| `wsl` | wsl | WSL NixOS | — (manual) | 24.11 | на vetymae (Windows 192.168.1.100) |
|
||||||
|
| `epral` | termux | Android | — | 24.05 | nix-on-droid, через `mobile.nix` |
|
||||||
|
|
||||||
|
## Подтверждённые инварианты
|
||||||
|
|
||||||
|
> Полные формулировки (с «Где» и «Почему») — в `.agent/rules/project-rules.md` (R1).
|
||||||
|
|
||||||
|
1. **Все `outputs` флейка должны вычисляться.** `configurations/mobile.nix:12` импортировал несуществующий `lib/xlib.nix` — был сломан, `epral` не собирался. → задача T1.
|
||||||
|
2. **External-диск обязан быть смонтирован** до старта `postgresql`, `n8n`, `samba`, `homebox`, `minecraft`, `3x-ui`, `tape-rotation`. → задача T4.
|
||||||
|
3. **Сетевая граница sapphira — роутер.** 5 портов: **443, 80, 22000 (syncthing), 8443 (xray), 22 (ssh)**. `firewall.enable = false` намеренно. → задача T11.
|
||||||
|
4. **`100.64.0.0` = Tailscale-адрес sapphira**, назначен вручную. В 4 файлах. → задача T12.
|
||||||
|
5. **3x-ui заморожен.** Панель на `:latest`, ядро Xray на 26.7.x. Миграция на 26.9.x провалена. → задачи T6–T10.
|
||||||
|
6. **nftables на VDS требует явной финальной политики.** Текущий ruleset — без финального правила → неявный accept. → задача T3.
|
||||||
|
7. **sops-пути — через `config.sops.secrets.<name>.path`.** Любой `path =` override на sops-блоке делает хардкод-потребителя молча сломанным. → ADR-0001.
|
||||||
|
|
||||||
|
## Ловушки (выглядит сломанным, намеренно)
|
||||||
|
|
||||||
|
| Где | Что выглядит ошибкой | На самом деле |
|
||||||
|
|---|---|---|
|
||||||
|
| `server.nix:130` | `firewall.enable = false` при 20 сервисах на `0.0.0.0` | Роутер фильтрует, см. инв. 3 |
|
||||||
|
| `mobile.nix:95`, `wsl.nix:59` | `stateVersion` 24.05 / 24.11 vs 26.05 | Каждый хост зафиксирован на своей версии |
|
||||||
|
| `users.nix:66` | `uid = if hostname == "sapphira" then 1001 else …` | Костыль под 1000 = удалённый `yuyus`; удалять только после миграции ФС |
|
||||||
|
| `3x-ui.nix:54` | `image = …:latest` | Панель намеренно latest; ядро Xray — на 26.7.x |
|
||||||
|
| `3x-ui.nix:33-35` | `reality443Forwarding = true` на VDS | Следствие отката `c8d4a12`; см. задачу T10 |
|
||||||
|
| `server/default.nix:33-47` | 15 закомментированных модулей | Отключены осознанно, см. задачу T16 |
|
||||||
|
| `opencode.nix:339` | `systemd.user.services.opencode-web.Service` | `serviceConfig` рендерится в секцию `[serviceConfig]`, systemd молча игнорирует; см. R2 |
|
||||||
|
| `vds.nix:73-91` | nftables без финального правила | Известный пробел, см. задачу T3 |
|
||||||
|
| `100.64.0.0` | Первый адрес CGNAT `/10` | Tailscale-адрес sapphira, см. инв. 4 |
|
||||||
|
| `server.nix:61-63` | `z /mnt/services 0777` | World-writable точка монтирования; см. задачу T4 |
|
||||||
|
|
||||||
|
## Куда лезть по задаче
|
||||||
|
|
||||||
|
| Задача | Файл |
|
||||||
|
|---|---|
|
||||||
|
| Добавить хост | `configurations/default.nix` + `configurations/<host>.nix` + `configurations/{hardware,disko}/<host>.nix` |
|
||||||
|
| Добавить системный сервис | `modules/server/<name>.nix`, добавить в `modules/server/default.nix:imports` |
|
||||||
|
| Добавить home-пакет | `home/<device_type>.nix` |
|
||||||
|
| Добавить кросс-модульную опцию | `modules/options.nix` |
|
||||||
|
| Изменить mount/имя пользователя | `lib/xlib/dirs.nix`, `lib/xlib/device.nix` |
|
||||||
|
| Изменить домен / сертификат | `modules/server/coredns.nix` + `modules/server/nginx.nix` (или `vds/`) |
|
||||||
|
| Sops-секрет | `secrets/<name>.<yaml\|json\|env\|ini>`; `users.nix:99` подключает `secrets/default.yaml`; dotenv/json — через `mkUserSecret` |
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# все outputs вычисляются
|
||||||
|
nix flake check
|
||||||
|
|
||||||
|
# правки применились на целевой хост
|
||||||
|
nix build .#nixosConfigurations.<host>.config.system.build.toplevel
|
||||||
|
|
||||||
|
# nixOnDroid
|
||||||
|
nix build .#nixOnDroidConfigurations.epral.config.system.build.toplevel
|
||||||
|
|
||||||
|
# внешний диск смонтирован (до рестарта сервисов на нём)
|
||||||
|
findmnt /home/oqyude/External
|
||||||
|
findmnt /mnt/services
|
||||||
|
|
||||||
|
# sops
|
||||||
|
sops --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Где НЕ лезть без ответа владельца
|
||||||
|
|
||||||
|
- `secrets/` (sops-encrypted, расшифровываются `/etc/ssh/id_ed25519` → циклический bootstrap).
|
||||||
|
- `let deploy` без проверки deploy-rs нод: `rydiwo` (ноутбук, может быть выключен).
|
||||||
|
- Любая правка, противоречащая «Подтверждённым инвариантам» выше.
|
||||||
|
- Ядро Xray 26.7.x → 26.9.x — миграция провалена, не повторять без отдельной задачи.
|
||||||
|
|
||||||
|
## Команды MetaAgent (on-demand)
|
||||||
|
|
||||||
|
- `/adr` — записать архитектурное решение
|
||||||
|
- `/red-team` — попытаться сломать дизайн
|
||||||
|
- `/risk-register` — зафиксировать допущения
|
||||||
|
- `/alt-arch` — описать альтернативу
|
||||||
|
- `/invariant-tests` — тесты-инварианты для ADR
|
||||||
|
|
||||||
|
## Жизненный цикл MetaAgent v3.0.0
|
||||||
|
|
||||||
|
```
|
||||||
|
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
|
||||||
|
```
|
||||||
|
|
||||||
|
Текущее состояние: см. `.agent/checkpoints.json` (`phases.init = completed`,
|
||||||
|
`phases.analyse = completed`, `phases.roadmap = completed`,
|
||||||
|
`phases.decomposition = completed`, `phases.execution = in_progress`).
|
||||||
+12
-15
@@ -1,18 +1,15 @@
|
|||||||
|
# Host: "default" (device: minimal)
|
||||||
|
#
|
||||||
|
# The host record lives in configurations/default.nix; this file is only the
|
||||||
|
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||||
{
|
{
|
||||||
deviceType = "minimal";
|
inputs,
|
||||||
modules = [
|
...
|
||||||
(
|
}:
|
||||||
{
|
{
|
||||||
inputs,
|
imports = [
|
||||||
...
|
inputs.self.nixosModules.default
|
||||||
}:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
inputs.self.nixosModules.default
|
|
||||||
];
|
|
||||||
|
|
||||||
system.stateVersion = "26.05";
|
|
||||||
}
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
system.stateVersion = "26.05";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,73 @@
|
|||||||
{ inputs, ... }@flakeContext:
|
{ inputs, ... }@flakeContext:
|
||||||
let
|
let
|
||||||
|
lib = inputs.nixpkgs.lib;
|
||||||
mkSystem = import ../lib/mkSystem.nix flakeContext;
|
mkSystem = import ../lib/mkSystem.nix flakeContext;
|
||||||
|
xlibLib = import ../lib/xlib { inherit lib; };
|
||||||
|
|
||||||
|
# One record per host. The attribute name IS the hostname, so it is written
|
||||||
|
# exactly once; `hostname` is only needed where the attribute name is not
|
||||||
|
# the real hostname (the `default` entry).
|
||||||
|
#
|
||||||
|
# device device type, must be a key of `devices` in lib/xlib/device.nix
|
||||||
|
# modules module body for this host
|
||||||
|
hosts = {
|
||||||
|
default = {
|
||||||
|
hostname = "nixos";
|
||||||
|
device = "minimal";
|
||||||
|
modules = [ ./any.nix ];
|
||||||
|
};
|
||||||
|
atoridu = {
|
||||||
|
device = "primary";
|
||||||
|
modules = [ ./mini-pc.nix ];
|
||||||
|
};
|
||||||
|
rydiwo = {
|
||||||
|
device = "secondary";
|
||||||
|
modules = [ ./mini-laptop.nix ];
|
||||||
|
};
|
||||||
|
otreca = {
|
||||||
|
device = "vds";
|
||||||
|
modules = [ ./vds.nix ];
|
||||||
|
};
|
||||||
|
sapphira = {
|
||||||
|
device = "server";
|
||||||
|
modules = [ ./server.nix ];
|
||||||
|
};
|
||||||
|
wsl = {
|
||||||
|
device = "wsl";
|
||||||
|
modules = [ ./wsl.nix ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
mkHost =
|
||||||
|
name:
|
||||||
|
{
|
||||||
|
device,
|
||||||
|
modules,
|
||||||
|
hostname ? name,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
xlib = xlibLib.mkXlib {
|
||||||
|
inherit hostname;
|
||||||
|
type = device;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit xlib;
|
||||||
|
system = mkSystem { inherit xlib modules; };
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
nixosConfigurations = {
|
nixosConfigurations = lib.mapAttrs' (
|
||||||
default = mkSystem (import ./any.nix); # default
|
name: spec: lib.nameValuePair name (mkHost name spec).system
|
||||||
atoridu = mkSystem (import ./mini-pc.nix); # atoridu
|
) hosts;
|
||||||
rydiwo = mkSystem (import ./mini-laptop.nix); # rydiwo
|
|
||||||
otreca = mkSystem (import ./vds.nix); # vds
|
# Per-host xlib values, for code that lives outside the module system
|
||||||
sapphira = mkSystem (import ./server.nix); # sapphira
|
# (deploy, overlays, pkgs).
|
||||||
wsl = mkSystem (import ./wsl.nix); # wsl
|
xlib = lib.mapAttrs' (name: spec: lib.nameValuePair name (mkHost name spec).xlib) hosts;
|
||||||
};
|
|
||||||
nixOnDroidConfigurations = {
|
nixOnDroidConfigurations = {
|
||||||
epral = import ./mobile.nix flakeContext; # epral (Android via nix-on-droid)
|
epral = import ./mobile.nix flakeContext; # epral (Android device via nix-on-droid)
|
||||||
# Alias so a plain `nix-on-droid switch` from a local clone
|
# Alias so a plain `nix-on-droid switch` from a local clone
|
||||||
# (~/.config/nix-on-droid) picks up the device config without `#epral`.
|
# (~/.config/nix-on-droid) picks up the device config without `#epral`.
|
||||||
default = import ./mobile.nix flakeContext;
|
default = import ./mobile.nix flakeContext;
|
||||||
|
|||||||
@@ -1,41 +1,37 @@
|
|||||||
|
# Host: "rydiwo" (device: secondary)
|
||||||
|
#
|
||||||
|
# The host record lives in configurations/default.nix; this file is only the
|
||||||
|
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||||
{
|
{
|
||||||
deviceType = "secondary";
|
lib,
|
||||||
hostname = "rydiwo";
|
pkgs,
|
||||||
modules = [
|
xlib,
|
||||||
(
|
inputs,
|
||||||
{
|
...
|
||||||
lib,
|
}:
|
||||||
pkgs,
|
{
|
||||||
xlib,
|
imports = with inputs; [
|
||||||
inputs,
|
nixos-hardware.nixosModules.chuwi-minibook-x
|
||||||
...
|
./hardware/mini-laptop.nix
|
||||||
}:
|
self.nixosModules.default
|
||||||
{
|
|
||||||
imports = with inputs; [
|
|
||||||
nixos-hardware.nixosModules.chuwi-minibook-x
|
|
||||||
./hardware/mini-laptop.nix
|
|
||||||
self.nixosModules.default
|
|
||||||
];
|
|
||||||
|
|
||||||
boot = {
|
|
||||||
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
|
|
||||||
loader = {
|
|
||||||
systemd-boot.enable = lib.mkDefault true;
|
|
||||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
fileSystems = xlib.helpers.mkNtfsMount {
|
|
||||||
path = xlib.dirs.lamet-drive;
|
|
||||||
uuid = "DC76BD3576BD116E";
|
|
||||||
mask = "0000";
|
|
||||||
};
|
|
||||||
|
|
||||||
xlib.ssh.enable = true;
|
|
||||||
hardware.intel-gpu-tools.enable = true;
|
|
||||||
|
|
||||||
system.stateVersion = "26.05";
|
|
||||||
}
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
boot = {
|
||||||
|
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
|
||||||
|
loader = {
|
||||||
|
systemd-boot.enable = lib.mkDefault true;
|
||||||
|
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
fileSystems = xlib.helpers.mkNtfsMount {
|
||||||
|
path = xlib.dirs.lamet-drive;
|
||||||
|
uuid = "DC76BD3576BD116E";
|
||||||
|
mask = "0000";
|
||||||
|
};
|
||||||
|
|
||||||
|
host.ssh.enable = true;
|
||||||
|
hardware.intel-gpu-tools.enable = true;
|
||||||
|
|
||||||
|
system.stateVersion = "26.05";
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-80
@@ -1,83 +1,84 @@
|
|||||||
|
# Host: "atoridu" (device: primary)
|
||||||
|
#
|
||||||
|
# The host record lives in configurations/default.nix; this file is only the
|
||||||
|
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||||
{
|
{
|
||||||
deviceType = "primary";
|
lib,
|
||||||
hostname = "atoridu";
|
pkgs,
|
||||||
modules = [
|
xlib,
|
||||||
(
|
inputs,
|
||||||
{
|
...
|
||||||
lib,
|
}:
|
||||||
pkgs,
|
{
|
||||||
xlib,
|
imports = with inputs; [
|
||||||
inputs,
|
./hardware/mini-pc.nix
|
||||||
...
|
./disko/mini-pc.nix
|
||||||
}:
|
./hardware/logitech.nix
|
||||||
{
|
self.nixosModules.default
|
||||||
imports = with inputs; [
|
|
||||||
./hardware/mini-pc.nix
|
|
||||||
./disko/mini-pc.nix
|
|
||||||
./hardware/logitech.nix
|
|
||||||
self.nixosModules.default
|
|
||||||
];
|
|
||||||
|
|
||||||
fileSystems = lib.listToAttrs (
|
|
||||||
map (xlib.helpers.mkNtfsMount) [
|
|
||||||
{
|
|
||||||
path = xlib.dirs.therima-drive;
|
|
||||||
uuid = "C0A2DDEFA2DDEA44";
|
|
||||||
enable = false;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
path = xlib.dirs.vetymae-drive;
|
|
||||||
uuid = "6408433908430A0E";
|
|
||||||
enable = false;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
path = xlib.dirs.soptur-drive;
|
|
||||||
uuid = "C00C56E40C56D54E";
|
|
||||||
enable = false;
|
|
||||||
}
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
boot = {
|
|
||||||
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
|
|
||||||
loader = {
|
|
||||||
systemd-boot.enable = lib.mkDefault true;
|
|
||||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
services.xserver = {
|
|
||||||
videoDrivers = [
|
|
||||||
"amdgpu"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
services.pipewire = {
|
|
||||||
enable = lib.mkDefault true;
|
|
||||||
systemWide = true;
|
|
||||||
alsa.enable = false;
|
|
||||||
alsa.support32Bit = true;
|
|
||||||
pulse.enable = true;
|
|
||||||
jack.enable = true;
|
|
||||||
extraConfig.pipewire = {
|
|
||||||
"99-default.conf" = {
|
|
||||||
"context.properties" = {
|
|
||||||
"default.clock.rate" = 96000;
|
|
||||||
"default.clock.allowed-rates" = [
|
|
||||||
44100
|
|
||||||
48000
|
|
||||||
96000
|
|
||||||
];
|
|
||||||
"default.clock.quantum" = 1024;
|
|
||||||
"default.clock.min-quantum" = 256;
|
|
||||||
"default.clock.max-quantum" = 2048;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
nixpkgs.config.pulseaudio = true;
|
|
||||||
|
|
||||||
system.stateVersion = "26.05";
|
|
||||||
}
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# mkNtfsMount returns a `{ "<path>" = { ... }; }` attrset (the shape
|
||||||
|
# fileSystems itself wants), so several mounts are combined with
|
||||||
|
# mergeAttrsList — not listToAttrs, which would demand `name`/`value`.
|
||||||
|
#
|
||||||
|
# These three ntfs3 drives are intentionally left unmounted. The entries
|
||||||
|
# are kept commented out rather than deleted, so restoring a drive is a
|
||||||
|
# matter of uncommenting its block. `enable = false` would declare a drive
|
||||||
|
# without mounting it; dropping the field mounts it.
|
||||||
|
fileSystems = lib.mergeAttrsList (
|
||||||
|
map (xlib.helpers.mkNtfsMount) [
|
||||||
|
# {
|
||||||
|
# path = xlib.dirs.therima-drive;
|
||||||
|
# uuid = "C0A2DDEFA2DDEA44";
|
||||||
|
# }
|
||||||
|
# {
|
||||||
|
# path = xlib.dirs.vetymae-drive;
|
||||||
|
# uuid = "6408433908430A0E";
|
||||||
|
# }
|
||||||
|
# {
|
||||||
|
# path = xlib.dirs.soptur-drive;
|
||||||
|
# uuid = "C00C56E40C56D54E";
|
||||||
|
# }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
boot = {
|
||||||
|
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
|
||||||
|
loader = {
|
||||||
|
systemd-boot.enable = lib.mkDefault true;
|
||||||
|
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
services.xserver = {
|
||||||
|
videoDrivers = [
|
||||||
|
"amdgpu"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
services.pipewire = {
|
||||||
|
enable = lib.mkDefault true;
|
||||||
|
systemWide = true;
|
||||||
|
alsa.enable = false;
|
||||||
|
alsa.support32Bit = true;
|
||||||
|
pulse.enable = true;
|
||||||
|
jack.enable = true;
|
||||||
|
extraConfig.pipewire = {
|
||||||
|
"99-default.conf" = {
|
||||||
|
"context.properties" = {
|
||||||
|
"default.clock.rate" = 96000;
|
||||||
|
"default.clock.allowed-rates" = [
|
||||||
|
44100
|
||||||
|
48000
|
||||||
|
96000
|
||||||
|
];
|
||||||
|
"default.clock.quantum" = 1024;
|
||||||
|
"default.clock.min-quantum" = 256;
|
||||||
|
"default.clock.max-quantum" = 2048;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
nixpkgs.config.pulseaudio = true;
|
||||||
|
|
||||||
|
system.stateVersion = "26.05";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ let
|
|||||||
# (essentials, users.nix, home-manager, sops-nix, disko, grub2-themes)
|
# (essentials, users.nix, home-manager, sops-nix, disko, grub2-themes)
|
||||||
# and nixpkgs.overlays are skipped so it evaluates under nix-on-droid's
|
# and nixpkgs.overlays are skipped so it evaluates under nix-on-droid's
|
||||||
# module system (class = "nixOnDroid").
|
# module system (class = "nixOnDroid").
|
||||||
|
xlib = import ../lib/xlib.nix { lib = inputs.nixpkgs.lib; };
|
||||||
nixOnDroidModule =
|
nixOnDroidModule =
|
||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
@@ -21,20 +22,15 @@ let
|
|||||||
inputs.self.nixosModules.strict
|
inputs.self.nixosModules.strict
|
||||||
];
|
];
|
||||||
|
|
||||||
xlib.device = {
|
|
||||||
type = "termux";
|
|
||||||
hostname = "epral";
|
|
||||||
};
|
|
||||||
|
|
||||||
# Login shell. nix-on-droid writes /etc/passwd from user.shell on every
|
# Login shell. nix-on-droid writes /etc/passwd from user.shell on every
|
||||||
# activation, so `chsh` is useless here — set it in nix instead.
|
# activation, so `chsh` is useless here — set it in nix instead.
|
||||||
# (default is bashInteractive)
|
# (default is bashInteractive)
|
||||||
user.shell = "${pkgs.zsh}/bin/zsh";
|
user.shell = "${pkgs.zsh}/bin/zsh";
|
||||||
|
|
||||||
# SSH user (matches `User oqyude` in the client's ~/.ssh/config).
|
# SSH user (matches the `User` entries in the client's ~/.ssh/config).
|
||||||
# Default is "nix-on-droid"; home stays at the read-only
|
# Default is "nix-on-droid"; home stays at the read-only
|
||||||
# /data/data/com.termux.nix/files/home either way.
|
# /data/data/com.termux.nix/files/home either way.
|
||||||
user.userName = "oqyude";
|
user.userName = xlib.device.username;
|
||||||
|
|
||||||
# Minimal termux settings (nix-on-droid options only:
|
# Minimal termux settings (nix-on-droid options only:
|
||||||
# environment.*, nix.*, time.*, user.*, system.*, android-integration.*)
|
# environment.*, nix.*, time.*, user.*, system.*, android-integration.*)
|
||||||
@@ -120,6 +116,13 @@ inputs.nix-on-droid.lib.nixOnDroidConfiguration {
|
|||||||
nixOnDroidModule
|
nixOnDroidModule
|
||||||
];
|
];
|
||||||
extraSpecialArgs = {
|
extraSpecialArgs = {
|
||||||
deviceType = "termux";
|
# `xlib` is the same value shape NixOS hosts get (lib/mkSystem.nix);
|
||||||
|
# the hostname lives here because nixOnDroidConfigurations is keyed by
|
||||||
|
# both "epral" and the "default" alias, so it cannot come from the
|
||||||
|
# attribute name.
|
||||||
|
xlib = xlib.mkXlib {
|
||||||
|
hostname = "epral";
|
||||||
|
type = "termux";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-80
@@ -1,83 +1,139 @@
|
|||||||
|
# Host: "sapphira" (device: server)
|
||||||
|
#
|
||||||
|
# The host record lives in configurations/default.nix; this file is only the
|
||||||
|
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||||
{
|
{
|
||||||
deviceType = "server";
|
lib,
|
||||||
hostname = "sapphira";
|
pkgs,
|
||||||
modules = [
|
xlib,
|
||||||
(
|
inputs,
|
||||||
{
|
...
|
||||||
lib,
|
}:
|
||||||
pkgs,
|
{
|
||||||
xlib,
|
imports = [
|
||||||
inputs,
|
./hardware/server.nix
|
||||||
...
|
inputs.self.nixosModules.default
|
||||||
}:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./hardware/server.nix
|
|
||||||
inputs.self.nixosModules.default
|
|
||||||
];
|
|
||||||
|
|
||||||
boot = {
|
|
||||||
# kernelPackages = pkgs.linuxPackages_xanmod_stable;
|
|
||||||
hardwareScan = true;
|
|
||||||
loader = {
|
|
||||||
systemd-boot.enable = lib.mkDefault true;
|
|
||||||
efi.canTouchEfiVariables = lib.mkDefault true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
hardware = {
|
|
||||||
bluetooth.enable = true;
|
|
||||||
graphics = {
|
|
||||||
enable = true;
|
|
||||||
extraPackages = with pkgs; [
|
|
||||||
intel-media-driver
|
|
||||||
intel-ocl
|
|
||||||
intel-vaapi-driver
|
|
||||||
];
|
|
||||||
};
|
|
||||||
intel-gpu-tools.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
fileSystems =
|
|
||||||
(xlib.helpers.mkExfatMount {
|
|
||||||
path = xlib.dirs.archive-drive;
|
|
||||||
label = "archive";
|
|
||||||
})
|
|
||||||
// (xlib.helpers.mkExfatMount {
|
|
||||||
path = xlib.dirs.mobile-drive;
|
|
||||||
uuid = "7EB1-DC99";
|
|
||||||
})
|
|
||||||
// (xlib.helpers.mkBindMount {
|
|
||||||
what = xlib.dirs.services-folder;
|
|
||||||
where = xlib.dirs.services-mnt-folder;
|
|
||||||
})
|
|
||||||
// {
|
|
||||||
# External drive
|
|
||||||
"${xlib.dirs.server-home}" = {
|
|
||||||
device = "/dev/disk/by-uuid/37e53ebc-5343-a94d-9fe2-0ca39e13a8de";
|
|
||||||
fsType = "ext4";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"z ${xlib.dirs.services-mnt-folder} 0777 root root -"
|
|
||||||
];
|
|
||||||
|
|
||||||
xlib.ssh.enable = true;
|
|
||||||
|
|
||||||
networking = {
|
|
||||||
networkmanager.enable = true;
|
|
||||||
firewall.enable = false;
|
|
||||||
# nameservers = [
|
|
||||||
# "192.168.1.1"
|
|
||||||
# "127.0.0.1"
|
|
||||||
# ];
|
|
||||||
};
|
|
||||||
|
|
||||||
system = {
|
|
||||||
stateVersion = "25.05";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
boot = {
|
||||||
|
# kernelPackages = pkgs.linuxPackages_xanmod_stable;
|
||||||
|
hardwareScan = true;
|
||||||
|
loader = {
|
||||||
|
systemd-boot.enable = lib.mkDefault true;
|
||||||
|
efi.canTouchEfiVariables = lib.mkDefault true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
hardware = {
|
||||||
|
bluetooth.enable = true;
|
||||||
|
graphics = {
|
||||||
|
enable = true;
|
||||||
|
extraPackages = with pkgs; [
|
||||||
|
intel-media-driver
|
||||||
|
intel-ocl
|
||||||
|
intel-vaapi-driver
|
||||||
|
];
|
||||||
|
};
|
||||||
|
intel-gpu-tools.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
fileSystems =
|
||||||
|
(xlib.helpers.mkExfatMount {
|
||||||
|
path = xlib.dirs.archive-drive;
|
||||||
|
label = "archive";
|
||||||
|
})
|
||||||
|
// (xlib.helpers.mkExfatMount {
|
||||||
|
path = xlib.dirs.mobile-drive;
|
||||||
|
uuid = "7EB1-DC99";
|
||||||
|
})
|
||||||
|
// (xlib.helpers.mkBindMount {
|
||||||
|
what = xlib.dirs.services-folder;
|
||||||
|
where = xlib.dirs.services-mnt-folder;
|
||||||
|
})
|
||||||
|
// {
|
||||||
|
# External drive
|
||||||
|
"${xlib.dirs.server-home}" = {
|
||||||
|
device = "/dev/disk/by-uuid/37e53ebc-5343-a94d-9fe2-0ca39e13a8de";
|
||||||
|
fsType = "ext4";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"z ${xlib.dirs.services-mnt-folder} 0777 root root -"
|
||||||
|
];
|
||||||
|
|
||||||
|
host.ssh.enable = true;
|
||||||
|
|
||||||
|
# Offload Nix builds to the WSL2 NixOS instance running on vetymae
|
||||||
|
# (Windows 11 host). Sapphira only has 2 logical cores; the WSL exposes
|
||||||
|
# 24 cores + 14 GiB. The matchBlock with ProxyCommand is generated by
|
||||||
|
# modules/server/builder.nix, the other side of the same option lives in
|
||||||
|
# modules/wsl/builder.nix.
|
||||||
|
#
|
||||||
|
# `proxyCommand` is what marks this builder as needing the SSH matchBlock
|
||||||
|
# (see modules/server/builder.nix). A builder reachable directly would
|
||||||
|
# omit it.
|
||||||
|
#
|
||||||
|
# ---- DISABLED 2026-10-04 ----
|
||||||
|
# Remote building temporarily turned off coordinator-side. `host.builder.clients`
|
||||||
|
# falls back to its default `[]` (declared in modules/options.nix), so
|
||||||
|
# modules/server/builder.nix's `lib.mkIf (clients != [])` never fires and no
|
||||||
|
# buildMachines / SSH blocks / distributedBuilds override get generated.
|
||||||
|
# All builds run locally on sapphira's 2 cores. Re-enable by removing the
|
||||||
|
# Nix comments on the block below (and on `host.builder.enable = true;`
|
||||||
|
# in configurations/wsl.nix).
|
||||||
|
#
|
||||||
|
# host.builder.clients = [
|
||||||
|
# {
|
||||||
|
# hostName = "vetymae-nix";
|
||||||
|
# sshUser = "oqyude";
|
||||||
|
# sshKey = "/root/.ssh/id_ed25519";
|
||||||
|
# # NixOS calls this `systems` (plural), not `systemTypes`. The
|
||||||
|
# # default is empty — every derivation is rejected. The WSL NixOS
|
||||||
|
# # runs on x86_64-linux, matching sapphira.
|
||||||
|
# systems = [ "x86_64-linux" ];
|
||||||
|
# # vetymae-nix drops kvm + nixos-test from its advertised
|
||||||
|
# # system-features (see modules/wsl/builder.nix). Listing them here
|
||||||
|
# # would not break anything (Nix intersects), but listing the
|
||||||
|
# # features the WSL actually has is the documented contract.
|
||||||
|
# supportedFeatures = [
|
||||||
|
# "benchmark"
|
||||||
|
# "big-parallel"
|
||||||
|
# ];
|
||||||
|
# mandatoryFeatures = [ ];
|
||||||
|
# maxJobs = 24;
|
||||||
|
# speedFactor = 0.5;
|
||||||
|
# # Keep the SSH session alive across many small builds in one daemon
|
||||||
|
# # session — compile-heavy workloads spam the daemon with hundreds of
|
||||||
|
# # derivations and ControlMaster collapses those into one Windows hop.
|
||||||
|
# # NB: `nix.buildMachines` has no `sshOptions` attribute, so the
|
||||||
|
# # ControlMaster directive lives in the SSH matchBlock instead (see
|
||||||
|
# # modules/server/builder.nix).
|
||||||
|
# #
|
||||||
|
# # The OpenSSH alias for this host (matches the user's
|
||||||
|
# # ~/.ssh/config so known_hosts entries do not collide with the
|
||||||
|
# # Windows OpenSSH entry on 127.0.0.1/vetymae) is consumed only by
|
||||||
|
# # the SSH matchBlock below — not by `nix.buildMachines`, which has
|
||||||
|
# # no such attribute.
|
||||||
|
# hostKeyAlias = "wsl-nixos-on-vetymae";
|
||||||
|
# # Use the Windows host's IP directly so the nix-daemon (running as
|
||||||
|
# # root, without the user's ~/.ssh/config) does not need a separate
|
||||||
|
# # `vetymae` host alias. With StrictHostKeyChecking=accept-new the
|
||||||
|
# # first connection adds the Windows host key to /root/.ssh/known_hosts.
|
||||||
|
# proxyCommand = "ssh oqyude@192.168.1.100 'wsl -d NixOS -- nc 127.0.0.1 22'";
|
||||||
|
# }
|
||||||
|
# ];
|
||||||
|
|
||||||
|
networking = {
|
||||||
|
networkmanager.enable = true;
|
||||||
|
firewall.enable = false;
|
||||||
|
# nameservers = [
|
||||||
|
# "192.168.1.1"
|
||||||
|
# "127.0.0.1"
|
||||||
|
# ];
|
||||||
|
};
|
||||||
|
|
||||||
|
system = {
|
||||||
|
stateVersion = "25.05";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-117
@@ -1,122 +1,122 @@
|
|||||||
|
# Host: "otreca" (device: vds)
|
||||||
|
#
|
||||||
|
# The host record lives in configurations/default.nix; this file is only the
|
||||||
|
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||||
{
|
{
|
||||||
deviceType = "vds";
|
lib,
|
||||||
hostname = "otreca";
|
modulesPath,
|
||||||
modules = [
|
pkgs,
|
||||||
(
|
xlib,
|
||||||
{
|
inputs,
|
||||||
config,
|
...
|
||||||
lib,
|
}:
|
||||||
modulesPath,
|
{
|
||||||
pkgs,
|
imports = [
|
||||||
xlib,
|
(modulesPath + "/installer/scan/not-detected.nix")
|
||||||
inputs,
|
(modulesPath + "/profiles/qemu-guest.nix")
|
||||||
...
|
|
||||||
}:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
(modulesPath + "/installer/scan/not-detected.nix")
|
|
||||||
(modulesPath + "/profiles/qemu-guest.nix")
|
|
||||||
|
|
||||||
./disko/vds.nix
|
./disko/vds.nix
|
||||||
./hardware/vds.nix
|
./hardware/vds.nix
|
||||||
|
|
||||||
inputs.self.nixosModules.default
|
inputs.self.nixosModules.default
|
||||||
];
|
|
||||||
|
|
||||||
boot = {
|
|
||||||
# kernelPackages = pkgs.linuxPackages_xanmod_stable;
|
|
||||||
hardwareScan = true;
|
|
||||||
loader = {
|
|
||||||
grub = {
|
|
||||||
enable = true;
|
|
||||||
device = "nodev";
|
|
||||||
useOSProber = false;
|
|
||||||
efiSupport = false;
|
|
||||||
};
|
|
||||||
systemd-boot.enable = lib.mkDefault false;
|
|
||||||
};
|
|
||||||
kernel.sysctl = {
|
|
||||||
"net.ipv4.tcp_syncookies" = 1;
|
|
||||||
"net.ipv4.tcp_max_syn_backlog" = 4096;
|
|
||||||
"net.ipv4.tcp_synack_retries" = 3;
|
|
||||||
"net.ipv4.tcp_syn_retries" = 3;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
xlib.ssh.enable = true;
|
|
||||||
services.openssh.openFirewall = true;
|
|
||||||
|
|
||||||
services.tailscale = {
|
|
||||||
enable = true;
|
|
||||||
openFirewall = true;
|
|
||||||
};
|
|
||||||
networking = {
|
|
||||||
nameservers = [
|
|
||||||
"1.1.1.1"
|
|
||||||
"8.8.8.8"
|
|
||||||
];
|
|
||||||
networkmanager.enable = true;
|
|
||||||
tempAddresses = "disabled";
|
|
||||||
dhcpcd = {
|
|
||||||
enable = true;
|
|
||||||
IPv6rs = false;
|
|
||||||
};
|
|
||||||
firewall = {
|
|
||||||
enable = true;
|
|
||||||
allowPing = true;
|
|
||||||
};
|
|
||||||
nftables = {
|
|
||||||
enable = true;
|
|
||||||
ruleset = ''
|
|
||||||
table inet filter {
|
|
||||||
chain input {
|
|
||||||
type filter hook input priority 0;
|
|
||||||
|
|
||||||
# loopback
|
|
||||||
iif lo accept
|
|
||||||
|
|
||||||
# уже установленные
|
|
||||||
ct state established,related accept
|
|
||||||
|
|
||||||
# РЕЖЕМ SYN СРАЗУ
|
|
||||||
tcp flags syn tcp dport {80,443} limit rate 20/second burst 40 packets accept
|
|
||||||
tcp flags syn tcp dport {80,443} drop
|
|
||||||
|
|
||||||
# остальное по необходимости
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
enableIPv6 = false;
|
|
||||||
interfaces.ens3 = {
|
|
||||||
useDHCP = true;
|
|
||||||
# ipv4.addresses = [
|
|
||||||
# {
|
|
||||||
# address = "31.57.158.109";
|
|
||||||
# prefixLength = 24;
|
|
||||||
# }
|
|
||||||
# ];
|
|
||||||
# ipv6.addresses = [
|
|
||||||
# {
|
|
||||||
# address = "2a13:7c00:6:102:f816:3eff:fe91:6b9e";
|
|
||||||
# prefixLength = 64;
|
|
||||||
# }
|
|
||||||
# ];
|
|
||||||
};
|
|
||||||
# defaultGateway = {
|
|
||||||
# address = "31.57.158.1";
|
|
||||||
# interface = "ens3";
|
|
||||||
# };
|
|
||||||
# defaultGateway6 = {
|
|
||||||
# address = "2a13:7c00:6:102::1";
|
|
||||||
# interface = "ens3";
|
|
||||||
# };
|
|
||||||
};
|
|
||||||
|
|
||||||
system = {
|
|
||||||
stateVersion = "25.05";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
boot = {
|
||||||
|
# kernelPackages = pkgs.linuxPackages_xanmod_stable;
|
||||||
|
hardwareScan = true;
|
||||||
|
loader = {
|
||||||
|
grub = {
|
||||||
|
enable = true;
|
||||||
|
device = "nodev";
|
||||||
|
useOSProber = false;
|
||||||
|
efiSupport = false;
|
||||||
|
};
|
||||||
|
systemd-boot.enable = lib.mkDefault false;
|
||||||
|
};
|
||||||
|
kernel.sysctl = {
|
||||||
|
"net.ipv4.tcp_syncookies" = 1;
|
||||||
|
"net.ipv4.tcp_max_syn_backlog" = 4096;
|
||||||
|
"net.ipv4.tcp_synack_retries" = 3;
|
||||||
|
"net.ipv4.tcp_syn_retries" = 3;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
host.ssh.enable = true;
|
||||||
|
# SSH is reachable only over Tailscale (not on the public internet).
|
||||||
|
# This otreca VDS is reached by deploy-rs and by oqyude over the
|
||||||
|
# tailnet, so exposing 22 to ens3 is pure attack surface.
|
||||||
|
services.openssh.openFirewall = false;
|
||||||
|
|
||||||
|
services.tailscale = {
|
||||||
|
enable = true;
|
||||||
|
openFirewall = true;
|
||||||
|
};
|
||||||
|
# Open port 22 only on the tailscale interface.
|
||||||
|
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ 22 ];
|
||||||
|
networking = {
|
||||||
|
nameservers = [
|
||||||
|
"1.1.1.1"
|
||||||
|
"8.8.8.8"
|
||||||
|
];
|
||||||
|
networkmanager.enable = true;
|
||||||
|
tempAddresses = "disabled";
|
||||||
|
dhcpcd = {
|
||||||
|
enable = true;
|
||||||
|
IPv6rs = false;
|
||||||
|
};
|
||||||
|
firewall = {
|
||||||
|
enable = true;
|
||||||
|
allowPing = true;
|
||||||
|
};
|
||||||
|
nftables = {
|
||||||
|
enable = true;
|
||||||
|
ruleset = ''
|
||||||
|
table inet filter {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0;
|
||||||
|
|
||||||
|
# loopback
|
||||||
|
iif lo accept
|
||||||
|
|
||||||
|
# уже установленные
|
||||||
|
ct state established,related accept
|
||||||
|
|
||||||
|
# РЕЖЕМ SYN СРАЗУ
|
||||||
|
tcp flags syn tcp dport {80,443} limit rate 20/second burst 40 packets accept
|
||||||
|
tcp flags syn tcp dport {80,443} drop
|
||||||
|
|
||||||
|
# остальное по необходимости
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
enableIPv6 = false;
|
||||||
|
interfaces.ens3 = {
|
||||||
|
useDHCP = true;
|
||||||
|
# ipv4.addresses = [
|
||||||
|
# {
|
||||||
|
# address = "31.57.158.109";
|
||||||
|
# prefixLength = 24;
|
||||||
|
# }
|
||||||
|
# ];
|
||||||
|
# ipv6.addresses = [
|
||||||
|
# {
|
||||||
|
# address = "2a13:7c00:6:102:f816:3eff:fe91:6b9e";
|
||||||
|
# prefixLength = 64;
|
||||||
|
# }
|
||||||
|
# ];
|
||||||
|
};
|
||||||
|
# defaultGateway = {
|
||||||
|
# address = "31.57.158.1";
|
||||||
|
# interface = "ens3";
|
||||||
|
# };
|
||||||
|
# defaultGateway6 = {
|
||||||
|
# address = "2a13:7c00:6:102::1";
|
||||||
|
# interface = "ens3";
|
||||||
|
# };
|
||||||
|
};
|
||||||
|
|
||||||
|
system = {
|
||||||
|
stateVersion = "25.05";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-41
@@ -1,44 +1,60 @@
|
|||||||
|
# Host: "wsl" (device: wsl)
|
||||||
|
#
|
||||||
|
# The host record lives in configurations/default.nix; this file is only the
|
||||||
|
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
|
||||||
{
|
{
|
||||||
deviceType = "wsl";
|
lib,
|
||||||
hostname = "wsl";
|
modulesPath,
|
||||||
modules = [
|
pkgs,
|
||||||
(
|
xlib,
|
||||||
{
|
inputs,
|
||||||
config,
|
...
|
||||||
lib,
|
}:
|
||||||
pkgs,
|
{
|
||||||
modulesPath,
|
imports = [
|
||||||
xlib,
|
inputs.nixos-wsl.nixosModules.default
|
||||||
inputs,
|
inputs.self.nixosModules.default
|
||||||
...
|
|
||||||
}:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
inputs.nixos-wsl.nixosModules.default
|
|
||||||
inputs.self.nixosModules.default
|
|
||||||
];
|
|
||||||
|
|
||||||
hardware = {
|
|
||||||
graphics.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
networking = {
|
|
||||||
firewall = {
|
|
||||||
enable = false;
|
|
||||||
allowPing = true;
|
|
||||||
};
|
|
||||||
enableIPv6 = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
wsl = {
|
|
||||||
enable = true;
|
|
||||||
startMenuLaunchers = true;
|
|
||||||
useWindowsDriver = true;
|
|
||||||
defaultUser = config.xlib.device.username;
|
|
||||||
};
|
|
||||||
|
|
||||||
system.stateVersion = "24.11";
|
|
||||||
}
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
hardware = {
|
||||||
|
graphics.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
networking = {
|
||||||
|
firewall = {
|
||||||
|
enable = false;
|
||||||
|
allowPing = true;
|
||||||
|
};
|
||||||
|
enableIPv6 = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
wsl = {
|
||||||
|
enable = true;
|
||||||
|
startMenuLaunchers = true;
|
||||||
|
useWindowsDriver = true;
|
||||||
|
defaultUser = xlib.device.username;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Enable SSH server on WSL NixOS so sapphira can drive it directly via a
|
||||||
|
# ProxyCommand chain through the Windows OpenSSH layer. The shared
|
||||||
|
# essentials/ssh.nix module wires host keys, sops-managed user keys, and
|
||||||
|
# passwordless key auth — nothing to repeat here.
|
||||||
|
host.ssh.enable = true;
|
||||||
|
|
||||||
|
# Advertise this WSL instance as a remote Nix builder for sapphira (2
|
||||||
|
# cores, the bottleneck host). All builder wiring — fixing the
|
||||||
|
# `system-features` to drop the unsupported `kvm`, and adding the SSH
|
||||||
|
# user `oqyude` to trusted-users — lives in modules/wsl/builder.nix.
|
||||||
|
#
|
||||||
|
# ---- DISABLED 2026-10-04 ----
|
||||||
|
# Remote building temporarily turned off builder-side. The default of
|
||||||
|
# `host.builder.enable` is `false` (modules/options.nix), so
|
||||||
|
# modules/wsl/builder.nix's `lib.mkIf enable` block is skipped: WSL
|
||||||
|
# keeps its default system-features and trusted-users, and no SSH-side
|
||||||
|
# state changes. Re-enable by uncommenting the assignment below and
|
||||||
|
# removing the DISABLED banner in configurations/server.nix.
|
||||||
|
#
|
||||||
|
# host.builder.enable = true;
|
||||||
|
|
||||||
|
system.stateVersion = "24.11";
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,9 @@ let
|
|||||||
path = inputs.deploy-rs.lib.x86_64-linux.activate.nixos inputs.self.nixosConfigurations.${hostname};
|
path = inputs.deploy-rs.lib.x86_64-linux.activate.nixos inputs.self.nixosConfigurations.${hostname};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
user = "${inputs.self.nixosConfigurations.default.config.xlib.device.username}";
|
# Login user for every deploy target. Read from the hoisted xlib instead of
|
||||||
|
# digging through a built NixOS configuration.
|
||||||
|
user = "${inputs.self.xlib.default.device.username}";
|
||||||
server = "sapphira";
|
server = "sapphira";
|
||||||
vds = "otreca";
|
vds = "otreca";
|
||||||
mini-laptop = "rydiwo";
|
mini-laptop = "rydiwo";
|
||||||
|
|||||||
Generated
+52
-77
@@ -33,11 +33,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1781152676,
|
"lastModified": 1789770686,
|
||||||
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
|
"narHash": "sha256-uZkBR7yHdIKUFB5SZdfgh1qkGfI3XmYmI/lTiquxbck=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "disko",
|
"repo": "disko",
|
||||||
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
|
"rev": "725ea35e410ad83be4931d1bff7e090eacaf3563",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -63,10 +63,7 @@
|
|||||||
},
|
},
|
||||||
"flake-parts": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": [
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
"justray",
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788450739,
|
"lastModified": 1788450739,
|
||||||
@@ -109,11 +106,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789482455,
|
"lastModified": 1790128814,
|
||||||
"narHash": "sha256-pD3qVlQ4mUCFoTWrYtxecpSitQMsgJvJIN0FUIDZoEU=",
|
"narHash": "sha256-6Gm9q+wW3E4Ey4F6wEbJAwaMsEK6hvCYfTW7yY64ZfA=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "efa3ccb4c3cc90d832eab232976379058fa75aa3",
|
"rev": "0b2f1129177f70c5f0f5d88bb53c49ca47d0bfc0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -124,17 +121,19 @@
|
|||||||
},
|
},
|
||||||
"justray": {
|
"justray": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": [
|
||||||
|
"flake-parts"
|
||||||
|
],
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789450388,
|
"lastModified": 1790165840,
|
||||||
"narHash": "sha256-30EGO2CUKyJ6VOJF9Zw3B5myrXRjjEmFXpkJOWC3KHI=",
|
"narHash": "sha256-nQ3uCXiUGTLD/UtuChc2XlStYg9a33PYrkDitmmqxW8=",
|
||||||
"owner": "luynrs",
|
"owner": "luynrs",
|
||||||
"repo": "justray",
|
"repo": "justray",
|
||||||
"rev": "cbe71cb1e71c9f29f7384e9039af4eca9af8ab3a",
|
"rev": "4073f3fb223613e4d780dafad6f93b5c266061a2",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -198,11 +197,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789446796,
|
"lastModified": 1790137578,
|
||||||
"narHash": "sha256-dsgZ/cC/ZcbvAM4ZZK2USpS5e34AVo0OlBkuBvzJufQ=",
|
"narHash": "sha256-luzO2Bo/RxUHqTPxBttSB1QVzM9Y5s+Iwpip6SjWdWo=",
|
||||||
"owner": "Infinidoge",
|
"owner": "Infinidoge",
|
||||||
"repo": "nix-minecraft",
|
"repo": "nix-minecraft",
|
||||||
"rev": "9552774a584b6fb0e3981f8b73372d65f36e68cf",
|
"rev": "2e6a1d1ceb4da6b6ffb3980bc7993b3d057cd4db",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -256,14 +255,16 @@
|
|||||||
},
|
},
|
||||||
"nixos-hardware": {
|
"nixos-hardware": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789417814,
|
"lastModified": 1789978172,
|
||||||
"narHash": "sha256-xV9fcqtH6jJLVBF3ocqBBwZkPKEke+BujhEwT4Ye9Tc=",
|
"narHash": "sha256-FIRXajv1pPZ+l6On6ekkmcQ6le0Zi0ncRUoR3nB0vKA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixos-hardware",
|
"repo": "nixos-hardware",
|
||||||
"rev": "d40fd26f323c898b0c195d41aa5efadd85f57832",
|
"rev": "9ebcb7766700d7e006d9505247bd7ce0426f4232",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -299,15 +300,18 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767892417,
|
"lastModified": 1790046670,
|
||||||
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
|
"narHash": "sha256-MYiI+CzL0tuWgRPjGsKCDHqYs2T3OzMlMQWOYWG0qso=",
|
||||||
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
|
"owner": "NixOS",
|
||||||
"type": "tarball",
|
"repo": "nixpkgs",
|
||||||
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
|
"rev": "6774f7bc253789b113a4f39285dc0fa100abeacc",
|
||||||
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"type": "tarball",
|
"owner": "NixOS",
|
||||||
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-docs": {
|
"nixpkgs-docs": {
|
||||||
@@ -342,19 +346,18 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789286504,
|
"lastModified": 1788057806,
|
||||||
"narHash": "sha256-eiEK7cKZORNEvX0GeF3RtNEF/JXhgf2RqSp3230q13E=",
|
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
|
||||||
"owner": "NixOS",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "ef34387ddd751e1ab8857adf4676492d32eb24ec",
|
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "nix-community",
|
||||||
"ref": "nixos-unstable",
|
"repo": "nixpkgs.lib",
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -429,11 +432,11 @@
|
|||||||
"zapret": "zapret"
|
"zapret": "zapret"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789400583,
|
"lastModified": 1790130850,
|
||||||
"narHash": "sha256-X7gKuUiIJOZQghNl2n7yO+XqHF0nayvrx+sXjJCFMxQ=",
|
"narHash": "sha256-k7c+KGZmNLP0ZB9jnKKJUXUTvEJC8A6kHQmZlcN8kNQ=",
|
||||||
"owner": "FUFSoB",
|
"owner": "FUFSoB",
|
||||||
"repo": "proxy-suite-flake",
|
"repo": "proxy-suite-flake",
|
||||||
"rev": "8ed4ab9a8d4650e43585e10ce51f5ffa790c4a54",
|
"rev": "8f65fa9c255e9350fb09f3d3cc9054034918bf49",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -447,6 +450,7 @@
|
|||||||
"deploy-rs": "deploy-rs",
|
"deploy-rs": "deploy-rs",
|
||||||
"disko": "disko",
|
"disko": "disko",
|
||||||
"flake-compat": "flake-compat",
|
"flake-compat": "flake-compat",
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
"grub2-themes": "grub2-themes",
|
"grub2-themes": "grub2-themes",
|
||||||
"home-manager": "home-manager",
|
"home-manager": "home-manager",
|
||||||
"justray": "justray",
|
"justray": "justray",
|
||||||
@@ -455,12 +459,11 @@
|
|||||||
"nix-systems": "nix-systems",
|
"nix-systems": "nix-systems",
|
||||||
"nixos-hardware": "nixos-hardware",
|
"nixos-hardware": "nixos-hardware",
|
||||||
"nixos-wsl": "nixos-wsl",
|
"nixos-wsl": "nixos-wsl",
|
||||||
"nixpkgs": "nixpkgs_2",
|
"nixpkgs": "nixpkgs",
|
||||||
"plasma-manager": "plasma-manager",
|
"plasma-manager": "plasma-manager",
|
||||||
"proxy-suite": "proxy-suite",
|
"proxy-suite": "proxy-suite",
|
||||||
"sops-nix": "sops-nix",
|
"sops-nix": "sops-nix",
|
||||||
"utils": "utils",
|
"utils": "utils"
|
||||||
"zeroq-credentials": "zeroq-credentials"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scss-reset": {
|
"scss-reset": {
|
||||||
@@ -486,11 +489,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788914643,
|
"lastModified": 1789890976,
|
||||||
"narHash": "sha256-4GuMPW90JSxXWDPUB9M+1m7fYbe3H0apOd86/zBQ2Kw=",
|
"narHash": "sha256-GKwH3zpy7tartuJMG0Rv/xUsdetG1QLmTVv8UKgJLmA=",
|
||||||
"owner": "Mic92",
|
"owner": "Mic92",
|
||||||
"repo": "sops-nix",
|
"repo": "sops-nix",
|
||||||
"rev": "13616fff713a9f94055c66f15687ebdc17a335df",
|
"rev": "7214124c20c1542c90deb54af50e2f53ae02711f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -499,24 +502,11 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"utils": {
|
"utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": [
|
||||||
|
"nix-systems"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1731533236,
|
"lastModified": 1731533236,
|
||||||
@@ -586,21 +576,6 @@
|
|||||||
"repo": "zapret-discord-youtube",
|
"repo": "zapret-discord-youtube",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"zeroq-credentials": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1772104025,
|
|
||||||
"narHash": "sha256-tX5I2lkwbB1leoib6Ao/Et0B1GYrn3vxw4DkFYX8uyM=",
|
|
||||||
"ref": "refs/heads/master",
|
|
||||||
"rev": "511fc5446b502ff111020bda6d57261648d62333",
|
|
||||||
"revCount": 75,
|
|
||||||
"type": "git",
|
|
||||||
"url": "ssh://git@github.com/oqyude/zeroq-credentials.git"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "ssh://git@github.com/oqyude/zeroq-credentials.git"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
{
|
{
|
||||||
description = "oqyude flake";
|
description = "oqyude flake";
|
||||||
inputs = {
|
inputs = {
|
||||||
# My
|
|
||||||
zeroq-credentials.url = "git+ssh://git@github.com/oqyude/zeroq-credentials.git"; # flake of creds
|
|
||||||
|
|
||||||
# nixpkgs
|
# nixpkgs
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
# nixpkgs-last-unstable.url = "github:NixOS/nixpkgs/6b4955211758ba47fac850c040a27f23b9b4008f";
|
# nixpkgs-master.url = "github:NixOS/nixpkgs/master";
|
||||||
|
# nixpkgs-last-unstable.url = "github:NixOS/nixpkgs/3497aa5c9457a9d88d71fa93a4a8368816fbeeba";
|
||||||
# nixpkgs-fingerprint.url = "github:NixOS/nixpkgs/nixos-24.11";
|
# nixpkgs-fingerprint.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||||
|
|
||||||
# nix-community
|
# nix-community
|
||||||
@@ -36,16 +34,25 @@
|
|||||||
url = "github:luynrs/justray";
|
url = "github:luynrs/justray";
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.follows = "nixpkgs";
|
nixpkgs.follows = "nixpkgs";
|
||||||
|
flake-parts.follows = "flake-parts";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
utils.url = "github:numtide/flake-utils";
|
utils = {
|
||||||
|
url = "github:numtide/flake-utils";
|
||||||
|
# flake-utils тянет systems (nix-systems/default) сам -> наследуем корневой, чтобы не плодить дубль-узел в flake.lock
|
||||||
|
inputs.systems.follows = "nix-systems";
|
||||||
|
};
|
||||||
flake-compat.url = "github:edolstra/flake-compat";
|
flake-compat.url = "github:edolstra/flake-compat";
|
||||||
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
|
nixos-hardware = {
|
||||||
|
url = "github:NixOS/nixos-hardware/master";
|
||||||
|
# без follows nixos-hardware лочит свой собственный nixpkgs (две копии в lock/store)
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
nix-systems.url = "github:nix-systems/default";
|
nix-systems.url = "github:nix-systems/default";
|
||||||
# nixos-facter-modules.url = "github:numtide/nixos-facter-modules";
|
# nixos-facter-modules.url = "github:numtide/nixos-facter-modules";
|
||||||
# flake-utils.url = "github:numtide/flake-utils";
|
# flake-utils.url = "github:numtide/flake-utils";
|
||||||
# flake-parts.url = "github:hercules-ci/flake-parts";
|
|
||||||
# noctalia = {
|
# noctalia = {
|
||||||
# url = "github:noctalia-dev/noctalia-shell";
|
# url = "github:noctalia-dev/noctalia-shell";
|
||||||
# inputs.nixpkgs.follows = "nixpkgs";
|
# inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
|||||||
+1
-5
@@ -53,11 +53,7 @@ let
|
|||||||
imports = [
|
imports = [
|
||||||
(./. + "/${xlib.device.type}.nix")
|
(./. + "/${xlib.device.type}.nix")
|
||||||
];
|
];
|
||||||
headless = builtins.elem xlib.device.type [
|
headless = xlib.isHeadless;
|
||||||
"server"
|
|
||||||
"vds"
|
|
||||||
"wsl"
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
sharedModules = [
|
sharedModules = [
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
# Declarative OpenCode + oh-my-openagent (oh-my-opencode) plugin setup.
|
||||||
|
#
|
||||||
|
# Mirrors ~/.config/opencode/ on the current workstation.
|
||||||
|
# Imported by home/server.nix (sapphira). Auto-enables programs.opencode.
|
||||||
|
#
|
||||||
|
# Files this module owns on disk:
|
||||||
|
# ~/.config/opencode/opencode.json <- programs.opencode.settings
|
||||||
|
# ~/.config/opencode/tui.json <- programs.opencode.tui
|
||||||
|
# ~/.omo/omo.jsonc <- oh-my-openagent plugin's PRIMARY
|
||||||
|
# runtime config (>= v5.x reads
|
||||||
|
# only this path; the legacy
|
||||||
|
# ~/.config/opencode/oh-my-openagent.json
|
||||||
|
# is read only by the migration shim).
|
||||||
|
# ~/.config/opencode/oh-my-openagent.json <- legacy mirror, kept so omo doctor
|
||||||
|
# and any downgrade that re-reads
|
||||||
|
# the old path see the same content.
|
||||||
|
#
|
||||||
|
# Override any field in the importing module if needed.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
xlib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
# Body of ~/.omo/omo.jsonc (and the legacy mirror).
|
||||||
|
# Loaded by the oh-my-openagent opencode plugin on startup.
|
||||||
|
#
|
||||||
|
# Plugins >= 5.x resolve their config from ~/.omo/omo.jsonc, NOT from
|
||||||
|
# ~/.config/opencode/oh-my-openagent.json. Pinning _migrations here prevents
|
||||||
|
# the 2026-07-opencode-config-unification migration from running on every
|
||||||
|
# startup and re-backing-up the file (which would otherwise leave us with
|
||||||
|
# an empty omo.jsonc that drops every agent override — see the journal entry
|
||||||
|
# below).
|
||||||
|
ohMyOpenagentConfig = {
|
||||||
|
"$schema" =
|
||||||
|
"https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json";
|
||||||
|
_migrations = [
|
||||||
|
"2026-07-opencode-config-unification"
|
||||||
|
"2026-08-reasoning-unification"
|
||||||
|
];
|
||||||
|
|
||||||
|
agents = {
|
||||||
|
sisyphus = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "max";
|
||||||
|
models = [
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
|
||||||
|
{ model = "opencode/big-pickle"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
hephaestus = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "medium";
|
||||||
|
};
|
||||||
|
oracle = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "xhigh";
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
librarian = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
};
|
||||||
|
explore = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
models = [
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"multimodal-looker" = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "low";
|
||||||
|
models = [
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
prometheus = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "high";
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
metis = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "high";
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "low";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
momus = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "xhigh";
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
atlas = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"sisyphus-junior" = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
{ model = "opencode/big-pickle"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
categories = {
|
||||||
|
"visual-engineering" = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "high";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
ultrabrain = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "xhigh";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
deep = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "medium";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
artistry = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "high";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
quick = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"unspecified-low" = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"unspecified-high" = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
writing = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
let
|
||||||
|
# nixpkgs ast-grep only ships binary `ast-grep`; omo's ast-grep skill probes
|
||||||
|
# for `sg` (or ast-grep). Provide both via a symlink wrapper.
|
||||||
|
astGrepWithSg = pkgs.runCommandLocal "ast-grep-with-sg" { } ''
|
||||||
|
mkdir -p $out/bin
|
||||||
|
ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/ast-grep
|
||||||
|
ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/sg
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
programs.opencode = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
# Extras available to opencode-wrapped (via --suffix PATH on the wrapper):
|
||||||
|
# pkgs.nodejs_22 — npx/npm for MCP servers (webpage-mcp) and omo's plugin loader
|
||||||
|
# pkgs.ast-grep — `sg` CLI; omo's ast-grep skill requires it (omo doctor)
|
||||||
|
# pkgs.bun — omo prefers bun; with bun on PATH, `omo doctor` skips node fallback
|
||||||
|
# pkgs.gh — GitHub CLI; omo's GitHub automation features require it
|
||||||
|
extraPackages = [
|
||||||
|
pkgs.nodejs_22
|
||||||
|
astGrepWithSg
|
||||||
|
pkgs.bun
|
||||||
|
pkgs.gh
|
||||||
|
];
|
||||||
|
|
||||||
|
# ~/.config/opencode/opencode.json
|
||||||
|
settings = {
|
||||||
|
plugin = [ "oh-my-openagent@latest" ];
|
||||||
|
mcp = {
|
||||||
|
webpage = {
|
||||||
|
type = "local";
|
||||||
|
command = [
|
||||||
|
"npx"
|
||||||
|
"-y"
|
||||||
|
"-p"
|
||||||
|
"webpage-mcp@latest"
|
||||||
|
"webpage-mcp-stdio"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ~/.config/opencode/tui.json
|
||||||
|
# Mirrors workstation: oh-my-openagent also registered for the TUI.
|
||||||
|
tui = {
|
||||||
|
plugin = [ "oh-my-openagent@latest" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ~/.omo/omo.jsonc — primary file the oh-my-openagent plugin reads at runtime
|
||||||
|
# (>= v5.x). This path lives outside XDG_CONFIG_HOME (~/.config), so use
|
||||||
|
# home.file rather than xdg.configFile.
|
||||||
|
#
|
||||||
|
# ~/.config/opencode/oh-my-openagent.json is kept as a legacy mirror so
|
||||||
|
# `omo doctor`, the migration shim, and any future downgrade that re-reads the
|
||||||
|
# old path see the same content.
|
||||||
|
#
|
||||||
|
# MIGRATION TRAP (do not just point back at the legacy path):
|
||||||
|
# The plugin runs a `2026-07-opencode-config-unification` migration on every
|
||||||
|
# startup that backs up ~/.omo/omo.jsonc and tries to rewrite it from
|
||||||
|
# ~/.config/opencode/oh-my-openagent.json. The backup directory name embeds
|
||||||
|
# the source's content-hashed store path; because HM does not delete the
|
||||||
|
# previous generation's store path until garbage collection, the same path
|
||||||
|
# is reused on every retry and omo logs "Migration backup path already
|
||||||
|
# exists" forever — meanwhile the user's agent overrides disappear and the
|
||||||
|
# plugin's built-in fallback chain (which references providers like
|
||||||
|
# kimi-for-coding that opencode's provider registry no longer ships) gets
|
||||||
|
# picked instead, surfacing as `ProviderModelNotFoundError:
|
||||||
|
# kimi-for-coding/kimi-for-coding-highspeed` on every subagent spawn.
|
||||||
|
# Pinning _migrations above makes the migration a no-op; the omo.jsonc
|
||||||
|
# below is the actual config the plugin sees.
|
||||||
|
home.file."${config.home.homeDirectory}/.omo/omo.jsonc".text = builtins.toJSON ohMyOpenagentConfig;
|
||||||
|
xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig;
|
||||||
|
|
||||||
|
# Same extras on the user's PATH too, so `omo doctor` and standalone invocations
|
||||||
|
# of `sg`, `gh`, `bun`, `npm`, `npx` work in the user's shell — not only inside
|
||||||
|
# the opencode-wrapped binary.
|
||||||
|
home.packages = [
|
||||||
|
pkgs.nodejs_22
|
||||||
|
astGrepWithSg
|
||||||
|
pkgs.bun
|
||||||
|
pkgs.gh
|
||||||
|
];
|
||||||
|
|
||||||
|
# Expose `opencode web` as a systemd user service. nginx on sapphira
|
||||||
|
# proxies https://opencode.zeroq.su -> 127.0.0.1:4096.
|
||||||
|
#
|
||||||
|
# --hostname 0.0.0.0 binds the listener to every interface (matches the
|
||||||
|
# "0.0.0.0" intent; nginx then reverse-proxies 127.0.0.1:4096 internally).
|
||||||
|
# --cors https://opencode.zeroq.su lets the browser session reach the
|
||||||
|
# server from that origin without CORS rejection.
|
||||||
|
#
|
||||||
|
# SECURITY: with no password, anyone reaching the upstream socket gets full
|
||||||
|
# opencode. Bind 0.0.0.0 + listener == bridge == shell. The password is
|
||||||
|
# supplied via sops-managed EnvironmentFile, declared in modules/users.nix
|
||||||
|
# and decrypted to a path hardcoded here (home-manager modules cannot read
|
||||||
|
# `config.sops.*` — sops-nix options are NixOS-only).
|
||||||
|
programs.opencode.web = {
|
||||||
|
enable = true;
|
||||||
|
environmentFile = xlib.dirs.opencode-server-env;
|
||||||
|
extraArgs = [
|
||||||
|
"--hostname"
|
||||||
|
"0.0.0.0"
|
||||||
|
"--cors"
|
||||||
|
"https://opencode.zeroq.su"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# RAM constraints for the opencode-web user service.
|
||||||
|
#
|
||||||
|
# Sapphira has 5.6 GiB RAM with a ~1 GiB baseline (syncthing + immich + gitea
|
||||||
|
# + x-ui + nextcloud php-fpm). When something else spikes (immich-ml jobs,
|
||||||
|
# syncthing indexer, etc.) the system OOM killer activates and picks the
|
||||||
|
# largest cgroup — opencode at ~260 MiB – 1.4 GiB peak was being chosen and
|
||||||
|
# systemd then restarted it every few seconds (`RestartSec=5`), masking the
|
||||||
|
# real cause as a "service crash". The 2026-10-04 incident was exactly this.
|
||||||
|
#
|
||||||
|
# Three knobs together make opencode stop being an OOM victim AND stop being
|
||||||
|
# the source of an OOM:
|
||||||
|
#
|
||||||
|
# MemoryHigh soft pressure threshold: kernel reclaims aggressively
|
||||||
|
# once the cgroup hits this. Process keeps running.
|
||||||
|
# MemoryMax hard cap: cgroup-local OOM kills Node if exceeded. The
|
||||||
|
# HOST survives — only this process dies, no restart storm.
|
||||||
|
# OOMScoreAdjust negative bias for the system-wide OOM killer: opencode
|
||||||
|
# is killed last, after syncthing/immich/etc.
|
||||||
|
# OOMPolicy "continue" — systemd does NOT auto-restart on cgroup
|
||||||
|
# OOM-kill. Without this, a spike triggers the same
|
||||||
|
# restart-loop the host saw today.
|
||||||
|
#
|
||||||
|
# Sizes are derived from observed peak (1.4 GiB at 16:36, 1.1 GiB at 16:59).
|
||||||
|
# MemoryHigh = 1G gives headroom for normal runs; MemoryMax = 2G caps
|
||||||
|
# pathological growth. Tweak both together if a workload legitimately
|
||||||
|
# needs more.
|
||||||
|
#
|
||||||
|
# Refs:
|
||||||
|
# https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html
|
||||||
|
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#OOMScoreAdjust=
|
||||||
|
# cgroup/OOM knobs added on top of the [Service] section emitted by
|
||||||
|
# `programs.opencode.web`. home-manager unions multiple definitions of the
|
||||||
|
# same systemd unit attrset, so ExecStart/Restart/EnvironmentFile from
|
||||||
|
# upstream and MemoryHigh/MemoryMax/OOMScoreAdjust/OOMPolicy from here
|
||||||
|
# land in the same [Service] block systemd actually reads.
|
||||||
|
#
|
||||||
|
# NOTE: do NOT use `serviceConfig = { ... }` — home-manager renders that
|
||||||
|
# as a literal `[serviceConfig]` section, which systemd silently ignores
|
||||||
|
# (`Unknown section 'serviceConfig'. Ignoring.`). The cgroup protection
|
||||||
|
# above would never take effect (verified on sapphira, c73a698).
|
||||||
|
systemd.user.services.opencode-web.Service = {
|
||||||
|
MemoryHigh = "1G";
|
||||||
|
MemoryMax = "2G";
|
||||||
|
OOMScoreAdjust = -900;
|
||||||
|
OOMPolicy = "continue";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Workaround: home-manager activation updates the GC root `current-home`
|
||||||
|
# only at the very end (line 358 of the generated activate script), AFTER all
|
||||||
|
# `home.activation.*` dag entries have run. So we cannot read current-home
|
||||||
|
# from a dag entry — it still points to the OLD generation at the time our
|
||||||
|
# script executes. Instead, read `new-home`, which the activator writes
|
||||||
|
# BEFORE any dag entry runs and which already points at the new generation.
|
||||||
|
#
|
||||||
|
# The versioned symlink (`home-manager-NN-link`) is found by following
|
||||||
|
# `home-manager` one hop rather than hardcoding `home-manager-24-link`,
|
||||||
|
# so this keeps working across HM major-version bumps.
|
||||||
|
home.activation.relinkHomeManager = lib.hm.dag.entryAfter [ ] ''
|
||||||
|
hmVersioned="$(readlink "$HOME/.local/state/nix/profiles/home-manager" 2>/dev/null || true)"
|
||||||
|
target="$HOME/.local/state/nix/profiles/$hmVersioned"
|
||||||
|
newGen="$(readlink -e "''${XDG_STATE_HOME:-$HOME/.local/state}/home-manager/gcroots/new-home" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$hmVersioned" && -n "$newGen" && "$(readlink -f "$target")" != "$newGen" ]]; then
|
||||||
|
echo "home-manager: relinking $target -> $newGen"
|
||||||
|
ln -sfn "$newGen" "$target"
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
}
|
||||||
+1
-1
@@ -14,7 +14,7 @@ let
|
|||||||
|
|
||||||
"${config.home.homeDirectory}/Games/PrismLaunchers/${config.home.username}" =
|
"${config.home.homeDirectory}/Games/PrismLaunchers/${config.home.username}" =
|
||||||
".local/share/PrismLauncher";
|
".local/share/PrismLauncher";
|
||||||
"${xlib.dirs.lamet-drive}/Users/oqyude/Music" = "Music";
|
"${xlib.dirs.lamet-drive}/Users/${xlib.device.username}/Music" = "Music";
|
||||||
};
|
};
|
||||||
mkLinks = lib.mapAttrs' (sourcePath: targetPath: {
|
mkLinks = lib.mapAttrs' (sourcePath: targetPath: {
|
||||||
name = targetPath;
|
name = targetPath;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
./minimal.nix
|
./minimal.nix
|
||||||
|
./modules/opencode.nix
|
||||||
];
|
];
|
||||||
home.file = xlib.helpers.mkSymlinks config {
|
home.file = xlib.helpers.mkSymlinks config {
|
||||||
"${config.home.homeDirectory}/External/Music" = "Music";
|
"${config.home.homeDirectory}/External/Music" = "Music";
|
||||||
|
|||||||
+8
-8
@@ -218,7 +218,7 @@
|
|||||||
enable = true;
|
enable = true;
|
||||||
settings = {
|
settings = {
|
||||||
user = {
|
user = {
|
||||||
name = "oqyude";
|
name = xlib.device.username;
|
||||||
email = "oqyude@gmail.com";
|
email = "oqyude@gmail.com";
|
||||||
};
|
};
|
||||||
pull = {
|
pull = {
|
||||||
@@ -250,31 +250,31 @@
|
|||||||
};
|
};
|
||||||
sapphira = {
|
sapphira = {
|
||||||
HostName = "192.168.1.20";
|
HostName = "192.168.1.20";
|
||||||
User = "oqyude";
|
User = xlib.device.username;
|
||||||
};
|
};
|
||||||
sapphira-tailscale = {
|
sapphira-tailscale = {
|
||||||
HostName = "100.64.0.0";
|
HostName = "100.64.0.0";
|
||||||
User = "oqyude";
|
User = xlib.device.username;
|
||||||
};
|
};
|
||||||
otreca-old = {
|
otreca-old = {
|
||||||
HostName = "217.60.3.12";
|
HostName = "217.60.3.12";
|
||||||
User = "oqyude";
|
User = xlib.device.username;
|
||||||
};
|
};
|
||||||
otreca = {
|
otreca = {
|
||||||
HostName = "109.248.161.5";
|
HostName = "109.248.161.5";
|
||||||
User = "oqyude";
|
User = xlib.device.username;
|
||||||
};
|
};
|
||||||
otreca-tailscale = {
|
otreca-tailscale = {
|
||||||
HostName = "100.64.1.0";
|
HostName = "100.64.1.0";
|
||||||
User = "oqyude";
|
User = xlib.device.username;
|
||||||
};
|
};
|
||||||
rydiwo = {
|
rydiwo = {
|
||||||
HostName = "192.168.1.102";
|
HostName = "192.168.1.102";
|
||||||
User = "oqyude";
|
User = xlib.device.username;
|
||||||
};
|
};
|
||||||
epral = {
|
epral = {
|
||||||
HostName = "192.168.1.101";
|
HostName = "192.168.1.101";
|
||||||
User = "oqyude";
|
User = xlib.device.username;
|
||||||
Port = 8022;
|
Port = 8022;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-12
@@ -2,26 +2,28 @@
|
|||||||
inputs,
|
inputs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
# Builds a NixOS system from a host record.
|
||||||
|
#
|
||||||
|
# `xlib` is the pure host value (lib/xlib.nix `mkXlib`) built in
|
||||||
|
# configurations/default.nix. It is handed to every module as the `xlib`
|
||||||
|
# argument, so modules read plain `xlib.*` data instead of `config.xlib.*`
|
||||||
|
# and the host record stays the single source of truth.
|
||||||
{
|
{
|
||||||
deviceType,
|
xlib,
|
||||||
hostname ? null,
|
|
||||||
modules ? [ ],
|
modules ? [ ],
|
||||||
system ? "x86_64-linux",
|
system ? "x86_64-linux",
|
||||||
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
lib = inputs.nixpkgs.lib;
|
lib = inputs.nixpkgs.lib;
|
||||||
in
|
in
|
||||||
lib.nixosSystem {
|
lib.nixosSystem {
|
||||||
inherit system;
|
inherit
|
||||||
modules = modules ++ [
|
system
|
||||||
{
|
modules
|
||||||
xlib.device = {
|
;
|
||||||
type = deviceType;
|
|
||||||
}
|
|
||||||
// lib.optionalAttrs (hostname != null) { inherit hostname; };
|
|
||||||
}
|
|
||||||
];
|
|
||||||
specialArgs = {
|
specialArgs = {
|
||||||
inherit deviceType inputs;
|
inherit inputs;
|
||||||
|
inherit xlib;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Pure host library: no module system involved.
|
||||||
|
#
|
||||||
|
# Aggregates the four concerns a host record is built from:
|
||||||
|
# device.nix identity + capability flags from the device type
|
||||||
|
# dirs.nix well-known paths, derived from username
|
||||||
|
# helpers.nix pure helper functions shared by modules
|
||||||
|
#
|
||||||
|
# `mkXlib` is called in flake-level code (configurations/default.nix) and
|
||||||
|
# handed to every module as the `xlib` argument via lib/mkSystem.nix, so
|
||||||
|
# modules read plain `xlib.*` values instead of `config.xlib.*` and nothing in
|
||||||
|
# xlib can be overridden per host — the host record is the only place to
|
||||||
|
# change it.
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
inherit (import ./device.nix { inherit lib; })
|
||||||
|
devices
|
||||||
|
mkDevice
|
||||||
|
;
|
||||||
|
|
||||||
|
# dirs.nix is itself a function of `username`, not an attrset.
|
||||||
|
mkDirs = import ./dirs.nix;
|
||||||
|
|
||||||
|
helpers = (import ./helpers.nix { inherit lib; });
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit
|
||||||
|
devices
|
||||||
|
helpers
|
||||||
|
mkDevice
|
||||||
|
mkDirs
|
||||||
|
;
|
||||||
|
|
||||||
|
# Full host record: identity + capability flags + well-known paths +
|
||||||
|
# shared helpers.
|
||||||
|
mkXlib =
|
||||||
|
{
|
||||||
|
hostname,
|
||||||
|
type,
|
||||||
|
username ? "oqyude",
|
||||||
|
uid ? 1000,
|
||||||
|
gid ? 1000,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
device = mkDevice {
|
||||||
|
inherit
|
||||||
|
hostname
|
||||||
|
type
|
||||||
|
username
|
||||||
|
uid
|
||||||
|
gid
|
||||||
|
;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
device = {
|
||||||
|
inherit
|
||||||
|
hostname
|
||||||
|
type
|
||||||
|
username
|
||||||
|
uid
|
||||||
|
gid
|
||||||
|
;
|
||||||
|
};
|
||||||
|
isDesktop = device.isDesktop;
|
||||||
|
isHeadless = device.isHeadless;
|
||||||
|
dirs = mkDirs username;
|
||||||
|
# Bind the host's ids into the mount helpers, so ntfs3/exfat options
|
||||||
|
# carry the same uid/gid the primary user actually has.
|
||||||
|
helpers = import ./helpers.nix {
|
||||||
|
inherit lib;
|
||||||
|
uid = device.uid;
|
||||||
|
gid = device.gid;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Supported device types and the identity record built from one.
|
||||||
|
#
|
||||||
|
# Single source of truth for host identity: hostname, type, username and the
|
||||||
|
# capability flags derived from the type. Replaces the old `lib.types.enum`
|
||||||
|
# in modules/options.nix and the hand-written type lists in modules/default.nix
|
||||||
|
# and home/home.nix.
|
||||||
|
let
|
||||||
|
devices = {
|
||||||
|
minimal = {
|
||||||
|
desktop = false;
|
||||||
|
headless = false;
|
||||||
|
};
|
||||||
|
primary = {
|
||||||
|
desktop = true;
|
||||||
|
headless = false;
|
||||||
|
};
|
||||||
|
secondary = {
|
||||||
|
desktop = true;
|
||||||
|
headless = false;
|
||||||
|
};
|
||||||
|
server = {
|
||||||
|
desktop = false;
|
||||||
|
headless = true;
|
||||||
|
};
|
||||||
|
vds = {
|
||||||
|
desktop = false;
|
||||||
|
headless = true;
|
||||||
|
};
|
||||||
|
wsl = {
|
||||||
|
desktop = false;
|
||||||
|
headless = true;
|
||||||
|
};
|
||||||
|
termux = {
|
||||||
|
desktop = false;
|
||||||
|
headless = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit devices;
|
||||||
|
|
||||||
|
# Unknown device type fails here, at flake level, with the valid list.
|
||||||
|
mkDevice =
|
||||||
|
{
|
||||||
|
hostname,
|
||||||
|
type,
|
||||||
|
username ? "oqyude",
|
||||||
|
# The primary user is pinned to 1000 rather than left to NixOS'
|
||||||
|
# nextfree logic: the mount helpers below write uid=/gid= into
|
||||||
|
# ntfs3/exfat options, and an NTFS/exFAT volume mounted with a
|
||||||
|
# different id shows every file as owned by `nobody`.
|
||||||
|
uid ? 1000,
|
||||||
|
gid ? 1000,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
capabilities =
|
||||||
|
devices.${type}
|
||||||
|
or (throw "xlib: unknown device type '${type}', expected one of ${lib.concatStringsSep ", " (builtins.attrNames devices)}");
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit
|
||||||
|
hostname
|
||||||
|
type
|
||||||
|
username
|
||||||
|
uid
|
||||||
|
gid
|
||||||
|
;
|
||||||
|
isDesktop = capabilities.desktop;
|
||||||
|
isHeadless = capabilities.headless;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Well-known paths. Everything derives from `username`, which is why the
|
||||||
|
# whole set can be computed outside the module system.
|
||||||
|
username:
|
||||||
|
let
|
||||||
|
user-home = "/home/${username}";
|
||||||
|
wsl-home = "/mnt/c/Users/${username}";
|
||||||
|
server-home = "${user-home}/External";
|
||||||
|
services-mnt-folder = "/mnt/services";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit
|
||||||
|
user-home
|
||||||
|
wsl-home
|
||||||
|
server-home
|
||||||
|
services-mnt-folder
|
||||||
|
;
|
||||||
|
opencode-server-env = "${user-home}/.config/opencode/server.env";
|
||||||
|
|
||||||
|
user-storage = "${user-home}/Storage";
|
||||||
|
wsl-storage = "${wsl-home}/Storage";
|
||||||
|
server-credentials = "${server-home}/Credentials/server";
|
||||||
|
storage = "${server-home}/Storage";
|
||||||
|
calibre-library = "${server-home}/Books-Library";
|
||||||
|
services-folder = "${server-home}/Services";
|
||||||
|
services-nodes-folder = "${services-mnt-folder}/nodes";
|
||||||
|
postgresql-folder = "${services-mnt-folder}/postgresql";
|
||||||
|
authelia-folder = "${services-mnt-folder}/authelia";
|
||||||
|
music-library = "${user-home}/Music";
|
||||||
|
|
||||||
|
archive-drive = "/mnt/archive";
|
||||||
|
lamet-drive = "/mnt/lamet";
|
||||||
|
mobile-drive = "/mnt/mobile";
|
||||||
|
therima-drive = "/mnt/therima";
|
||||||
|
vetymae-drive = "/mnt/vetymae";
|
||||||
|
soptur-drive = "/mnt/soptur";
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
|
# The primary user's ids, bound from xlib.device by mkXlib. ntfs3/exfat
|
||||||
|
# volumes carry POSIX ids, so a mount using anything other than the real
|
||||||
|
# uid/gid shows every file as owned by `nobody`.
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# Shared pure helper functions for module definitions.
|
# Pure helper functions for module definitions.
|
||||||
# Injected into every module via `xlib.helpers` (see options.nix).
|
# Injected into every module via `xlib.helpers` (see default.nix).
|
||||||
|
#
|
||||||
|
# Defined in a `let` because they reference each other (mkTmpDirs uses
|
||||||
|
# mkTmpfile, mkServiceStorage uses mkTmpDirs + mkSystemdBind).
|
||||||
let
|
let
|
||||||
# tmpfiles rule: "type dir mode user group -"
|
# tmpfiles rule: "type dir mode user group -"
|
||||||
mkTmpfile =
|
mkTmpfile =
|
||||||
@@ -102,8 +110,8 @@ let
|
|||||||
fsType = "ntfs3";
|
fsType = "ntfs3";
|
||||||
options = [
|
options = [
|
||||||
"defaults"
|
"defaults"
|
||||||
"uid=1000"
|
"uid=${toString uid}"
|
||||||
"gid=1000"
|
"gid=${toString gid}"
|
||||||
"fmask=${mask}"
|
"fmask=${mask}"
|
||||||
"dmask=${mask}"
|
"dmask=${mask}"
|
||||||
"nofail"
|
"nofail"
|
||||||
@@ -125,8 +133,8 @@ let
|
|||||||
fsType = "exfat";
|
fsType = "exfat";
|
||||||
options = [
|
options = [
|
||||||
"nofail"
|
"nofail"
|
||||||
"uid=1000"
|
"uid=${toString uid}"
|
||||||
"gid=1000"
|
"gid=${toString gid}"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
+96
-102
@@ -7,7 +7,11 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/3x-ui";
|
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/3x-ui";
|
||||||
certDomain = xlib.services."3x-ui".certDomain or null;
|
# Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/) gets mounted
|
||||||
|
# read-only into the 3x-ui container so the panel can terminate TLS itself.
|
||||||
|
# Null when 3x-ui serves plain HTTP and TLS is terminated by an upstream
|
||||||
|
# nginx.
|
||||||
|
certDomain = config.host."3x-ui".certDomain;
|
||||||
certMounts =
|
certMounts =
|
||||||
if certDomain == null then
|
if certDomain == null then
|
||||||
[ ]
|
[ ]
|
||||||
@@ -20,117 +24,107 @@ let
|
|||||||
"key.pem"
|
"key.pem"
|
||||||
];
|
];
|
||||||
basePorts = [
|
basePorts = [
|
||||||
# 2049/tcp — 3x-ui web panel
|
# Local-only upstreams for the 3x-ui panel and subscription endpoint.
|
||||||
# 2096/tcp — subscription endpoint
|
# The direct Xray inbound remains publicly reachable on 8443.
|
||||||
# 14380-15379/tcp+udp — Xray inbounds (matches firewall open range)
|
"127.0.0.1:2049:2049/tcp"
|
||||||
"0.0.0.0:2049:2049/tcp"
|
"127.0.0.1:2096:2096/tcp"
|
||||||
"0.0.0.0:2096:2096/tcp"
|
"0.0.0.0:8443:8443/tcp"
|
||||||
"0.0.0.0:14380-15379:14380-15379/tcp"
|
|
||||||
"0.0.0.0:14380-15379:14380-15379/udp"
|
|
||||||
];
|
];
|
||||||
# VDS-only: nginx stream forwards host:443 → host:15380 → container:443,
|
# VDS-only: nginx stream forwards host:443 → 127.0.0.1:15380 →
|
||||||
# so Xray inside the container sees its REALITY inbound on its real
|
# container:443, so Xray sees its REALITY inbound on port 443.
|
||||||
# configured port 443.
|
realityPorts = lib.optional config.host."3x-ui".reality443Forwarding "127.0.0.1:15380:443/tcp";
|
||||||
realityPorts = lib.optional xlib.services."3x-ui".reality443Forwarding "0.0.0.0:15380:443/tcp";
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
virtualisation = {
|
# `host."3x-ui"` options are declared in modules/options.nix: they are set
|
||||||
podman = {
|
# by modules/server and modules/vds, so this module cannot be the only place
|
||||||
enable = true;
|
# that knows they exist.
|
||||||
autoPrune = {
|
config = {
|
||||||
|
virtualisation = {
|
||||||
|
podman = {
|
||||||
enable = true;
|
enable = true;
|
||||||
flags = [ "--all" ];
|
autoPrune = {
|
||||||
};
|
enable = true;
|
||||||
dockerCompat = true;
|
flags = [ "--all" ];
|
||||||
};
|
|
||||||
oci-containers = {
|
|
||||||
backend = "podman";
|
|
||||||
containers."3xui_app" = {
|
|
||||||
image = "ghcr.io/mhsanaei/3x-ui:v3.8.5";
|
|
||||||
environment = {
|
|
||||||
"XRAY_VMESS_AEAD_FORCED" = "false";
|
|
||||||
"XUI_ENABLE_FAIL2BAN" = "true";
|
|
||||||
"TZ" = "Europe/Moscow";
|
|
||||||
};
|
};
|
||||||
volumes = [
|
dockerCompat = true;
|
||||||
"${panel}/cert/:/root/cert:rw"
|
|
||||||
"${panel}/db/:/etc/x-ui:rw"
|
|
||||||
]
|
|
||||||
++ certMounts;
|
|
||||||
log-driver = "journald";
|
|
||||||
# Adding a new inbound through the 3x-ui panel on a port outside
|
|
||||||
# the 14380-15379 range requires extending basePorts and rebuilding.
|
|
||||||
ports = basePorts ++ realityPorts;
|
|
||||||
};
|
};
|
||||||
};
|
oci-containers = {
|
||||||
};
|
backend = "podman";
|
||||||
|
containers."3xui_app" = {
|
||||||
systemd = {
|
image = "ghcr.io/mhsanaei/3x-ui:latest";
|
||||||
services = {
|
environment = {
|
||||||
"podman-3xui_app" = {
|
"XRAY_VMESS_AEAD_FORCED" = "false";
|
||||||
serviceConfig.Restart = lib.mkOverride 90 "always";
|
"XUI_ENABLE_FAIL2BAN" = "true";
|
||||||
partOf = [ "podman-compose-3x-ui-root.target" ];
|
"TZ" = "Europe/Moscow";
|
||||||
wantedBy = [ "podman-compose-3x-ui-root.target" ];
|
};
|
||||||
};
|
volumes = [
|
||||||
"podman-update-3xui_app" = {
|
"${panel}/cert/:/root/cert:rw"
|
||||||
path = [ pkgs.podman ];
|
"${panel}/db/:/etc/x-ui:rw"
|
||||||
serviceConfig = {
|
]
|
||||||
Type = "oneshot";
|
++ certMounts;
|
||||||
TimeoutSec = 300;
|
log-driver = "journald";
|
||||||
|
# Adding a new inbound through the 3x-ui panel on a port outside
|
||||||
|
# the 14380-15379 range requires extending basePorts and rebuilding.
|
||||||
|
ports = basePorts ++ realityPorts;
|
||||||
};
|
};
|
||||||
script = ''
|
|
||||||
podman pull ghcr.io/mhsanaei/3x-ui:latest
|
|
||||||
systemctl restart podman-3xui_app.service
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
# Starts/stops together with all 3x-ui compose resources.
|
|
||||||
targets."podman-compose-3x-ui-root" = {
|
|
||||||
unitConfig.Description = "Root target generated by compose2nix.";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
};
|
|
||||||
timers."podman-update-3xui_app" = {
|
|
||||||
wantedBy = [ "timers.target" ];
|
|
||||||
timerConfig = {
|
|
||||||
OnCalendar = "weekly";
|
|
||||||
Persistent = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
tmpfiles.rules = [
|
|
||||||
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
|
|
||||||
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root")
|
|
||||||
(xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
|
|
||||||
"root"
|
|
||||||
"root"
|
|
||||||
)
|
|
||||||
(xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
|
|
||||||
(xlib.helpers.mkTmpfile "d" "${panel}/db" "0755" "root" "root")
|
|
||||||
(xlib.helpers.mkTmpfile "d" "${panel}/cert" "0755" "root" "root")
|
|
||||||
# Relabel panel dir for SELinux so containers can access it.
|
|
||||||
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Enable container name DNS for all Podman networks.
|
systemd = {
|
||||||
networking.firewall = {
|
services = {
|
||||||
allowedUDPPortRanges = [
|
"podman-3xui_app" = {
|
||||||
{
|
serviceConfig.Restart = lib.mkOverride 90 "always";
|
||||||
from = 14380;
|
partOf = [ "podman-compose-3x-ui-root.target" ];
|
||||||
to = 15380;
|
wantedBy = [ "podman-compose-3x-ui-root.target" ];
|
||||||
}
|
};
|
||||||
];
|
"podman-update-3xui_app" = {
|
||||||
allowedTCPPortRanges = [
|
path = [ pkgs.podman ];
|
||||||
{
|
serviceConfig = {
|
||||||
from = 14380;
|
Type = "oneshot";
|
||||||
to = 15380;
|
TimeoutSec = 300;
|
||||||
}
|
};
|
||||||
];
|
script = ''
|
||||||
interfaces =
|
podman pull ghcr.io/mhsanaei/3x-ui:latest
|
||||||
let
|
systemctl restart podman-3xui_app.service
|
||||||
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
|
'';
|
||||||
in
|
};
|
||||||
{
|
|
||||||
"${matchAll}".allowedUDPPorts = [ 53 ];
|
|
||||||
};
|
};
|
||||||
|
# Starts/stops together with all 3x-ui compose resources.
|
||||||
|
targets."podman-compose-3x-ui-root" = {
|
||||||
|
unitConfig.Description = "Root target generated by compose2nix.";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
};
|
||||||
|
# timers."podman-update-3xui_app" = {
|
||||||
|
# wantedBy = [ "timers.target" ];
|
||||||
|
# timerConfig = {
|
||||||
|
# OnCalendar = "weekly";
|
||||||
|
# Persistent = true;
|
||||||
|
# };
|
||||||
|
# };
|
||||||
|
tmpfiles.rules = [
|
||||||
|
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
|
||||||
|
"root"
|
||||||
|
"root"
|
||||||
|
)
|
||||||
|
(xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${panel}/db" "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${panel}/cert" "0755" "root" "root")
|
||||||
|
# Relabel panel dir for SELinux so containers can access it.
|
||||||
|
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Enable container name DNS for all Podman networks.
|
||||||
|
networking.firewall = {
|
||||||
|
interfaces =
|
||||||
|
let
|
||||||
|
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
"${matchAll}".allowedUDPPorts = [ 53 ];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
# The image is built here rather than pulled: zaakirio/kokoro-ru is a
|
||||||
|
# Hugging Face repo, not a published OCI image, and its Russian G2P has to be
|
||||||
|
# driven through the repo's own ru_g2p.py.
|
||||||
|
#
|
||||||
|
# The build context goes through the store so the image is pinned to the
|
||||||
|
# config revision: edit a file, `nixos-rebuild`, and the unit below rebuilds
|
||||||
|
# and restarts. Reading the context off a checkout at runtime would leave the
|
||||||
|
# running container untraceable back to any config.
|
||||||
|
#
|
||||||
|
# runCommand rather than linkFarm: linkFarm entries are symlinks into other
|
||||||
|
# store paths, and `podman build` only mounts the context root, so every COPY
|
||||||
|
# fails with "copier: get: lstat ...: no such file or directory". Copying the
|
||||||
|
# bytes in leaves the context with no symlinks that escape its root.
|
||||||
|
source = pkgs.runCommand "kokoro-tts-source" { } ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
cp -L ${./kokoro-tts/Dockerfile} "$out/Dockerfile"
|
||||||
|
cp -L ${./kokoro-tts/app.py} "$out/app.py"
|
||||||
|
cp -L ${./kokoro-tts/fetch_assets.py} "$out/fetch_assets.py"
|
||||||
|
cp -L ${./kokoro-tts/requirements.txt} "$out/requirements.txt"
|
||||||
|
'';
|
||||||
|
|
||||||
|
image = "localhost/kokoro-tts:latest";
|
||||||
|
|
||||||
|
# Unchanged from the silero module, so whatever already points at
|
||||||
|
# http://127.0.0.1:9898/v1 keeps working without edits.
|
||||||
|
hostPort = 9898;
|
||||||
|
containerPort = 8000;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = {
|
||||||
|
virtualisation = {
|
||||||
|
podman = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
autoPrune = {
|
||||||
|
enable = true;
|
||||||
|
flags = [ "--all" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
dockerCompat = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
oci-containers = {
|
||||||
|
backend = "podman";
|
||||||
|
|
||||||
|
containers.kokoro-tts = {
|
||||||
|
image = image;
|
||||||
|
|
||||||
|
ports = [
|
||||||
|
"127.0.0.1:${toString hostPort}:${toString containerPort}"
|
||||||
|
];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
# Inference is CPU-bound and already threaded inside torch. Measured
|
||||||
|
# on a 24-logical-core host: median end-to-end latency for a 5.6 s
|
||||||
|
# utterance was 1.203 s at 4 threads, 0.979 s at 12, 0.980 s at 16
|
||||||
|
# and 1.87 s at 24, so the useful ceiling is the physical core count
|
||||||
|
# and oversubscribing it roughly doubles the wait. These three must
|
||||||
|
# stay equal to the Dockerfile ENV and the app.py default: whichever
|
||||||
|
# of the three is set wins over the others.
|
||||||
|
KOKORO_THREADS = "12";
|
||||||
|
OMP_NUM_THREADS = "12";
|
||||||
|
MKL_NUM_THREADS = "12";
|
||||||
|
TZ = "Europe/Moscow";
|
||||||
|
};
|
||||||
|
|
||||||
|
# No volumes: the checkpoints, the acute-aware espeak data and
|
||||||
|
# ruaccent's ONNX models are all baked into the image, so the
|
||||||
|
# container needs neither a host directory nor the network to start.
|
||||||
|
log-driver = "journald";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd = {
|
||||||
|
services = {
|
||||||
|
# Runs before the container. BuildKit caches the expensive layers, so
|
||||||
|
# on every boot after the first this is a no-op that still verifies the
|
||||||
|
# image exists.
|
||||||
|
"podman-build-kokoro-tts" = {
|
||||||
|
path = [ pkgs.podman ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
# First build pulls torch wheels plus ~700 MB of weights.
|
||||||
|
TimeoutSec = 3600;
|
||||||
|
};
|
||||||
|
|
||||||
|
script = ''
|
||||||
|
podman build -t ${image} ${source}
|
||||||
|
'';
|
||||||
|
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
"podman-kokoro-tts" = {
|
||||||
|
# The image does not exist until the build above ran, and a `latest`
|
||||||
|
# tag must be re-pulled on rebuild, so ordering has to be explicit.
|
||||||
|
after = [ "podman-build-kokoro-tts.service" ];
|
||||||
|
requires = [ "podman-build-kokoro-tts.service" ];
|
||||||
|
serviceConfig.Restart = lib.mkOverride 90 "always";
|
||||||
|
# Auto-start disabled: start manually with `systemctl start podman-kokoro-tts`.
|
||||||
|
wantedBy = [ ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Fully qualified on purpose: NixOS ships a podman registries.conf without
|
||||||
|
# unqualified-search-registries, so a bare "python:3.12-slim-bookworm" fails to
|
||||||
|
# resolve before the build even starts.
|
||||||
|
FROM docker.io/library/python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# Pinned, not "main": a rebuild that only touched the Nix module must not
|
||||||
|
# silently pick up different weights. Bump these deliberately.
|
||||||
|
ARG KOKORO_RU_REPO=zaakirio/kokoro-ru
|
||||||
|
ARG KOKORO_RU_REVISION=d649c57b239b18c4c384378127cbf01dba039bc1
|
||||||
|
# Trim to "sveta" to halve the image: masha shares her checkpoint and dima is
|
||||||
|
# a second 327 MB one.
|
||||||
|
ARG KOKORO_RU_VOICES=sveta,masha,dima
|
||||||
|
|
||||||
|
# Thread counts, not a guess: see app.py THREADS. 12 was the measured plateau on
|
||||||
|
# a 24-logical-core host, and 24 was ~2x worse. Must stay equal to the Nix
|
||||||
|
# module's environment.environment, which wins over this ENV.
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
HF_HUB_DISABLE_TELEMETRY=1 \
|
||||||
|
HF_HUB_DISABLE_SYMLINKS_WARNING=1 \
|
||||||
|
KOKORO_RU_REPO=${KOKORO_RU_REPO} \
|
||||||
|
KOKORO_RU_REVISION=${KOKORO_RU_REVISION} \
|
||||||
|
KOKORO_RU_VOICES=${KOKORO_RU_VOICES} \
|
||||||
|
KOKORO_MODEL_DIR=/app/kokoro-ru \
|
||||||
|
KOKORO_THREADS=12 \
|
||||||
|
OMP_NUM_THREADS=12 \
|
||||||
|
MKL_NUM_THREADS=12 \
|
||||||
|
TZ=Europe/Moscow
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# libgomp1 is torch's OpenMP runtime. espeak-ng comes from the espeakng-loader
|
||||||
|
# wheel rather than the distro package because the model needs its own
|
||||||
|
# recompiled ru_dict, and libsndfile is absent because WAV/PCM are written with
|
||||||
|
# stdlib `wave` while every other format goes through imageio-ffmpeg.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# CPU-only torch from its own index: the default PyPI wheel drags in ~2.5 GB of
|
||||||
|
# CUDA libraries for a machine that has no GPU.
|
||||||
|
RUN pip install --index-url https://download.pytorch.org/whl/cpu torch
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
# fetch_assets.py is copied on its own and app.py only after the snapshot, never
|
||||||
|
# as one COPY. A single COPY would tie the 639 MB download to the application
|
||||||
|
# source: any edit to app.py would invalidate this layer and refetch every
|
||||||
|
# checkpoint as hundreds of anonymous, rate-limited requests.
|
||||||
|
COPY fetch_assets.py ./
|
||||||
|
|
||||||
|
# Bakes the checkpoints, the acute-aware espeak data and ruaccent's ONNX models
|
||||||
|
# into the layer, which is what lets the container start with no network and no
|
||||||
|
# writable volume.
|
||||||
|
RUN python fetch_assets.py
|
||||||
|
|
||||||
|
COPY app.py ./
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
|
||||||
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4)"]
|
||||||
|
|
||||||
|
# No workers: the model is a shared in-process singleton, so a second worker
|
||||||
|
# would only mean a second copy of ~2 GB of weights.
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
"""OpenAI-compatible TTS API backed by zaakirio/kokoro-ru.
|
||||||
|
|
||||||
|
The model itself is language-blind: phoneme ids in, 24 kHz audio out. All the
|
||||||
|
Russian lives in the G2P front-end, and the one that matters is kokoro-ru's own
|
||||||
|
`ru_g2p.py` — RUAccent resolves lexical stress, ё and homographs, then an
|
||||||
|
acute-aware espeak-ng phonemizer turns that into IPA. Stock misaki Russian is
|
||||||
|
espeak-only and gets stress wrong often enough that the model reads as
|
||||||
|
non-native (measured 27% vs 22% round-trip WER, per the model card).
|
||||||
|
|
||||||
|
So: text -> RuG2P.phonemize -> KModel(ipa, voicepack[len(ipa) - 1]) -> waveform.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
POST /v1/audio/speech OpenAI text-to-speech
|
||||||
|
POST /v1/audio/speech/stream same, but mp3/opus emitted while synthesising
|
||||||
|
GET /v1/models OpenAI model list
|
||||||
|
GET /v1/voices voice inventory (extension, not part of OpenAI)
|
||||||
|
GET /healthz readiness, 503 until the model is loaded
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import wave
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Iterator, Literal
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # torch is imported lazily so /healthz answers during boot
|
||||||
|
import torch
|
||||||
|
|
||||||
|
MODEL_ID = "kokoro-ru"
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
MODEL_DIR = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
|
||||||
|
DEFAULT_VOICE = os.environ.get("KOKORO_DEFAULT_VOICE", "sveta")
|
||||||
|
# Measured on the host this was tuned for (Ryzen AI 9 HX 370, 24 logical cores):
|
||||||
|
# median end-to-end latency for a 5.6 s utterance was 1.203 s @ 4 threads,
|
||||||
|
# 1.066 s @ 8, 0.979 s @ 12, 0.980 s @ 16, then 1.87 s @ 24. The gain stops at
|
||||||
|
# the physical core count and SMT oversubscription costs ~2x, so cap instead of
|
||||||
|
# trusting os.cpu_count(), which reports logical CPUs. Override on other hosts.
|
||||||
|
THREADS = int(os.environ.get("KOKORO_THREADS", min(12, os.cpu_count() or 4)))
|
||||||
|
# 2026-07-29, when the kokoro-ru revision we pin was published. Clients that
|
||||||
|
# cache on this treat any change as a new model, so it must stay stable.
|
||||||
|
MODEL_CREATED = 1785353253
|
||||||
|
|
||||||
|
# voice -> (checkpoint stem, gender). The checkpoint carries the timbre and the
|
||||||
|
# voicepack the identity, which is why sveta and masha share one file.
|
||||||
|
VOICE_SPECS: dict[str, tuple[str, str]] = {
|
||||||
|
"sveta": ("kokoro-ru-v2-base", "female"),
|
||||||
|
"masha": ("kokoro-ru-v2-base", "female"),
|
||||||
|
"dima": ("kokoro-ru-v2-dima", "male"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clients that ship the OpenAI voice list (alloy, nova, echo, ...) send those
|
||||||
|
# names unless the user overrides them, so map them onto the three we have.
|
||||||
|
VOICE_ALIASES: dict[str, str] = {
|
||||||
|
"alloy": "sveta",
|
||||||
|
"ash": "sveta",
|
||||||
|
"ballad": "sveta",
|
||||||
|
"verse": "sveta",
|
||||||
|
"marin": "sveta",
|
||||||
|
"coral": "masha",
|
||||||
|
"sage": "masha",
|
||||||
|
"shimmer": "masha",
|
||||||
|
"cedar": "masha",
|
||||||
|
"echo": "dima",
|
||||||
|
"fable": "dima",
|
||||||
|
"onyx": "dima",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTENT_TYPES = {
|
||||||
|
"wav": "audio/wav",
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
"opus": "audio/ogg",
|
||||||
|
"aac": "audio/aac",
|
||||||
|
"flac": "audio/flac",
|
||||||
|
"pcm": "audio/pcm",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything except wav and pcm goes through ffmpeg; those two are byte-exact
|
||||||
|
# from the stdlib and need no encoder at all.
|
||||||
|
FFMPEG_ARGS = {
|
||||||
|
"mp3": ["-c:a", "libmp3lame", "-q:a", "2"],
|
||||||
|
"opus": ["-c:a", "libopus", "-b:a", "64k"],
|
||||||
|
"aac": ["-c:a", "aac", "-b:a", "128k"],
|
||||||
|
"flac": ["-c:a", "flac"],
|
||||||
|
}
|
||||||
|
FFMPEG_CONTAINERS = {"mp3": "mp3", "opus": "ogg", "aac": "adts", "flac": "flac"}
|
||||||
|
|
||||||
|
# Kokoro's Albert context is 510 tokens and KModel.forward asserts
|
||||||
|
# len(ids) + 2 <= 510, so 508 phonemes is the hard ceiling per forward pass.
|
||||||
|
MAX_PHONEMES = 508
|
||||||
|
# Roughly 300 characters of Russian lands near 400 phonemes, comfortably under
|
||||||
|
# the ceiling, and keeps a chunk short enough that a bad sentence is a short
|
||||||
|
# chunk.
|
||||||
|
CHUNK_CHARS = 300
|
||||||
|
# Silence inserted between chunks. Without it the concatenation clicks at every
|
||||||
|
# boundary because each forward pass starts and ends on a zero crossing.
|
||||||
|
CHUNK_GAP_S = 0.08
|
||||||
|
|
||||||
|
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
log = logging.getLogger("kokoro-ru")
|
||||||
|
|
||||||
|
|
||||||
|
def split_text(text: str, budget: int = CHUNK_CHARS) -> list[str]:
|
||||||
|
"""Split into sentence-bounded chunks, hard-cutting only as a last resort.
|
||||||
|
|
||||||
|
Phonemizing per sentence rather than per paragraph keeps RUAccent's stress
|
||||||
|
decisions local and gives the model a reset point at every full stop.
|
||||||
|
"""
|
||||||
|
chunks: list[str] = []
|
||||||
|
current = ""
|
||||||
|
for sentence in _SENTENCE_SPLIT.split(text.strip()):
|
||||||
|
sentence = sentence.strip()
|
||||||
|
while len(sentence) > budget:
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = ""
|
||||||
|
chunks.append(sentence[:budget])
|
||||||
|
sentence = sentence[budget:].strip()
|
||||||
|
if not sentence:
|
||||||
|
continue
|
||||||
|
if len(current) + len(sentence) + 1 > budget:
|
||||||
|
# Guarded: when the first sentence fills the budget exactly, or the
|
||||||
|
# previous one was hard-cut down to nothing, `current` is empty and
|
||||||
|
# a bare append would queue a zero-length chunk.
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = sentence
|
||||||
|
else:
|
||||||
|
current = f"{current} {sentence}".strip()
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def split_phonemes(ps: str, limit: int = MAX_PHONEMES) -> list[str]:
|
||||||
|
"""Cut an over-long phoneme string on word boundaries."""
|
||||||
|
if len(ps) <= limit:
|
||||||
|
return [ps]
|
||||||
|
parts: list[str] = []
|
||||||
|
rest = ps
|
||||||
|
while len(rest) > limit:
|
||||||
|
cut = rest.rfind(" ", 0, limit)
|
||||||
|
if cut <= 0:
|
||||||
|
cut = limit
|
||||||
|
parts.append(rest[:cut].strip())
|
||||||
|
rest = rest[cut:].strip()
|
||||||
|
if rest:
|
||||||
|
parts.append(rest)
|
||||||
|
return [part for part in parts if part]
|
||||||
|
|
||||||
|
|
||||||
|
class KokoroRu:
|
||||||
|
"""Loaded model plus the G2P front-end, behind a single inference lock."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._torch: torch | None = None
|
||||||
|
self._g2p = None
|
||||||
|
self._models: dict[str, torch.nn.Module] = {}
|
||||||
|
self._packs: dict[str, torch.Tensor] = {}
|
||||||
|
# The Albert encoder and the iSTFTNet decoder keep per-call scratch
|
||||||
|
# buffers; concurrent forwards on one model interleave into them. The
|
||||||
|
# model is fast enough on CPU that serialising is not the bottleneck.
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
import torch
|
||||||
|
from kokoro import KModel
|
||||||
|
|
||||||
|
torch.set_num_threads(THREADS)
|
||||||
|
self._torch = torch
|
||||||
|
|
||||||
|
# RuG2P is imported from the baked snapshot, not installed, and it
|
||||||
|
# resolves espeak-data/ plus kokoro-config.json next to itself.
|
||||||
|
sys.path.insert(0, str(MODEL_DIR))
|
||||||
|
from ru_g2p import RuG2P
|
||||||
|
|
||||||
|
self._g2p = RuG2P(
|
||||||
|
espeak_data=MODEL_DIR / "espeak-data",
|
||||||
|
vocab_path=MODEL_DIR / "kokoro-config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
for stem in sorted({stem for stem, _ in VOICE_SPECS.values()}):
|
||||||
|
checkpoint = MODEL_DIR / f"{stem}.pth"
|
||||||
|
if not checkpoint.exists():
|
||||||
|
log.warning("checkpoint %s missing, voices using it stay unavailable", checkpoint)
|
||||||
|
continue
|
||||||
|
# repo_id is only used to build the default model filename; passing
|
||||||
|
# both config and model keeps it from touching the HF cache at all.
|
||||||
|
self._models[stem] = KModel(
|
||||||
|
repo_id=str(MODEL_DIR),
|
||||||
|
config=str(MODEL_DIR / "config.json"),
|
||||||
|
model=str(checkpoint),
|
||||||
|
).eval()
|
||||||
|
log.info("loaded checkpoint %s", checkpoint.name)
|
||||||
|
|
||||||
|
for name in VOICE_SPECS:
|
||||||
|
pack = MODEL_DIR / "voices" / f"{name}.pt"
|
||||||
|
if pack.exists():
|
||||||
|
self._packs[name] = torch.load(str(pack), map_location="cpu", weights_only=True)
|
||||||
|
|
||||||
|
if not self.available_voices():
|
||||||
|
raise RuntimeError(f"no usable voices under {MODEL_DIR}")
|
||||||
|
|
||||||
|
def available_voices(self) -> list[str]:
|
||||||
|
return [
|
||||||
|
name
|
||||||
|
for name in VOICE_SPECS
|
||||||
|
if name in self._packs and VOICE_SPECS[name][0] in self._models
|
||||||
|
]
|
||||||
|
|
||||||
|
def phonemes(self, text: str):
|
||||||
|
for chunk in split_text(text):
|
||||||
|
ps, _oov = self._g2p.phonemize(chunk)
|
||||||
|
ps = ps.strip()
|
||||||
|
if ps:
|
||||||
|
yield from split_phonemes(ps)
|
||||||
|
|
||||||
|
def iter_audio_chunks(self, text: str, voice: str, speed: float):
|
||||||
|
"""Yields float32 audio per phoneme chunk, silence gaps interleaved.
|
||||||
|
|
||||||
|
The engine lock is held for the whole iteration, so a caller that stops
|
||||||
|
consuming early releases synthesis for everyone else.
|
||||||
|
"""
|
||||||
|
torch = self._torch
|
||||||
|
assert torch is not None, "synthesize() before load()"
|
||||||
|
stem, _gender = VOICE_SPECS[voice]
|
||||||
|
model = self._models[stem]
|
||||||
|
pack = self._packs[voice]
|
||||||
|
|
||||||
|
gap = np.zeros(int(CHUNK_GAP_S * SAMPLE_RATE), dtype=np.float32)
|
||||||
|
with self._lock:
|
||||||
|
for index, ps in enumerate(self.phonemes(text)):
|
||||||
|
# The style vector is picked by phoneme-string length, which is
|
||||||
|
# why the model sounds deterministic for identical text.
|
||||||
|
style = pack[len(ps) - 1]
|
||||||
|
# The packs ship as [510, 256]; KModel wants a batch of one.
|
||||||
|
if style.dim() == 1:
|
||||||
|
style = style.unsqueeze(0)
|
||||||
|
if index:
|
||||||
|
yield gap
|
||||||
|
yield np.asarray(
|
||||||
|
model(ps, style, speed, return_output=True).audio,
|
||||||
|
dtype=np.float32,
|
||||||
|
).reshape(-1)
|
||||||
|
|
||||||
|
def synthesize(self, text: str, voice: str, speed: float) -> np.ndarray:
|
||||||
|
chunks = list(self.iter_audio_chunks(text, voice, speed))
|
||||||
|
if not chunks:
|
||||||
|
return np.zeros(0, dtype=np.float32)
|
||||||
|
return np.concatenate(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def encode(audio: np.ndarray, fmt: str) -> bytes:
|
||||||
|
clipped = np.clip(audio, -1.0, 1.0)
|
||||||
|
if fmt == "pcm":
|
||||||
|
# OpenAI's pcm is raw signed 16-bit little-endian mono at 24 kHz.
|
||||||
|
return (clipped * 32767.0).astype("<i2").tobytes()
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with wave.open(buffer, "wb") as out:
|
||||||
|
out.setnchannels(1)
|
||||||
|
out.setsampwidth(2)
|
||||||
|
out.setframerate(SAMPLE_RATE)
|
||||||
|
out.writeframes((clipped * 32767.0).astype("<i2").tobytes())
|
||||||
|
wav = buffer.getvalue()
|
||||||
|
|
||||||
|
if fmt == "wav":
|
||||||
|
return wav
|
||||||
|
|
||||||
|
import imageio_ffmpeg
|
||||||
|
|
||||||
|
command = [
|
||||||
|
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
"-ar",
|
||||||
|
str(SAMPLE_RATE),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
*FFMPEG_ARGS[fmt],
|
||||||
|
"-f",
|
||||||
|
FFMPEG_CONTAINERS[fmt],
|
||||||
|
"pipe:1",
|
||||||
|
]
|
||||||
|
done = subprocess.run(command, input=wav, capture_output=True, check=False)
|
||||||
|
if done.returncode != 0:
|
||||||
|
raise RuntimeError(done.stderr.decode("utf-8", "replace").strip()[-400:])
|
||||||
|
return done.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEncoder:
|
||||||
|
"""One long-lived ffmpeg per request: raw PCM in, encoded bytes out.
|
||||||
|
|
||||||
|
A single process is what keeps the container valid. Handing it the audio in
|
||||||
|
pieces as they are synthesised avoids any byte-level concatenation, whereas
|
||||||
|
encoding the pieces separately and joining the results would emit chained
|
||||||
|
Ogg for opus, which plenty of players reject.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, fmt: str) -> None:
|
||||||
|
import imageio_ffmpeg
|
||||||
|
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
"-ar",
|
||||||
|
str(SAMPLE_RATE),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
*FFMPEG_ARGS[fmt],
|
||||||
|
"-f",
|
||||||
|
FFMPEG_CONTAINERS[fmt],
|
||||||
|
"pipe:1",
|
||||||
|
],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
self._blocks: queue.Queue[bytes | None] = queue.Queue()
|
||||||
|
self._reader = threading.Thread(target=self._pump, daemon=True)
|
||||||
|
self._reader.start()
|
||||||
|
|
||||||
|
def _pump(self) -> None:
|
||||||
|
assert self._proc.stdout is not None
|
||||||
|
while True:
|
||||||
|
block = self._proc.stdout.read(8192)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
self._blocks.put(block)
|
||||||
|
self._blocks.put(None)
|
||||||
|
|
||||||
|
def push(self, audio: np.ndarray) -> None:
|
||||||
|
assert self._proc.stdin is not None
|
||||||
|
clipped = np.clip(audio, -1.0, 1.0)
|
||||||
|
self._proc.stdin.write((clipped * 32767.0).astype("<i2").tobytes())
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
|
||||||
|
def drain(self) -> Iterator[bytes]:
|
||||||
|
"""Yields whatever ffmpeg has already emitted, without waiting for more."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
block = self._blocks.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
return
|
||||||
|
if block is None:
|
||||||
|
return
|
||||||
|
yield block
|
||||||
|
|
||||||
|
def finish(self) -> Iterator[bytes]:
|
||||||
|
assert self._proc.stdin is not None
|
||||||
|
self._proc.stdin.close()
|
||||||
|
self._reader.join(timeout=120)
|
||||||
|
code = self._proc.wait(timeout=30)
|
||||||
|
error = self._proc.stderr.read().decode("utf-8", "replace").strip()[-400:]
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(error or f"ffmpeg exited with {code}")
|
||||||
|
yield from self.drain()
|
||||||
|
|
||||||
|
def abort(self) -> None:
|
||||||
|
if self._proc.poll() is None:
|
||||||
|
self._proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
engine = KokoroRu()
|
||||||
|
state: dict[str, str | None] = {"status": "loading", "error": None}
|
||||||
|
|
||||||
|
|
||||||
|
def boot() -> None:
|
||||||
|
try:
|
||||||
|
engine.load()
|
||||||
|
state["status"] = "ready"
|
||||||
|
log.info("ready: voices=%s", ", ".join(engine.available_voices()))
|
||||||
|
except Exception as exc:
|
||||||
|
state["status"] = "error"
|
||||||
|
state["error"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
log.exception("model failed to load")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
# Off the event loop: loading pulls ~700 MB of weights and runs three ONNX
|
||||||
|
# sessions, and /healthz has to stay answerable while it happens.
|
||||||
|
threading.Thread(target=boot, name="kokoro-load", daemon=True).start()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="kokoro-ru OpenAI TTS", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
Format = Literal["mp3", "opus", "aac", "flac", "wav", "pcm"]
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechRequest(BaseModel):
|
||||||
|
# `protected_namespaces` silences pydantic's warning about the `model_`
|
||||||
|
# prefix; `extra="ignore"` absorbs the fields newer OpenAI clients add
|
||||||
|
# (instructions, the legacy `format` alias) without failing the request.
|
||||||
|
model_config = ConfigDict(extra="ignore", protected_namespaces=())
|
||||||
|
|
||||||
|
input: str = Field(min_length=1)
|
||||||
|
model: str = MODEL_ID
|
||||||
|
voice: str | None = None
|
||||||
|
response_format: Format = "wav"
|
||||||
|
speed: float | None = Field(default=None, ge=0.25, le=4.0)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamSpeechRequest(SpeechRequest):
|
||||||
|
# Streaming needs a container that tolerates unknown length up front, so wav
|
||||||
|
# (whose header declares the final sizes) and the raw formats are out. mp3
|
||||||
|
# and opus emit bytes as they go, which is the whole point of the endpoint.
|
||||||
|
response_format: Literal["mp3", "opus"] = "mp3"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(status: int, message: str, param: str | None = None, code: str | None = None) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status,
|
||||||
|
content={
|
||||||
|
"error": {
|
||||||
|
"message": message,
|
||||||
|
"type": "invalid_request_error" if status < 500 else "server_error",
|
||||||
|
"param": param,
|
||||||
|
"code": code,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_voice(requested: str | None) -> str | None:
|
||||||
|
name = (requested or DEFAULT_VOICE).strip().lower()
|
||||||
|
name = VOICE_ALIASES.get(name, name)
|
||||||
|
return name if name in engine.available_voices() else None
|
||||||
|
|
||||||
|
|
||||||
|
# response_model=None: the handler returns a Response subclass directly, and
|
||||||
|
# FastAPI would otherwise try to build a Pydantic model out of the union.
|
||||||
|
@app.post("/v1/audio/speech", response_model=None)
|
||||||
|
def create_speech(request: SpeechRequest) -> Response | JSONResponse:
|
||||||
|
if state["status"] != "ready":
|
||||||
|
return fail(503, f"model is not ready: {state['status']}", code="model_not_ready")
|
||||||
|
|
||||||
|
voice = resolve_voice(request.voice)
|
||||||
|
if voice is None:
|
||||||
|
available = ", ".join(engine.available_voices())
|
||||||
|
return fail(
|
||||||
|
400,
|
||||||
|
f"unknown voice {request.voice!r}; available: {available}",
|
||||||
|
param="voice",
|
||||||
|
code="unknown_voice",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio = engine.synthesize(request.input, voice, request.speed or 1.0)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("synthesis failed")
|
||||||
|
return fail(500, f"synthesis failed: {exc}", code="synthesis_failed")
|
||||||
|
|
||||||
|
if audio.size == 0:
|
||||||
|
return fail(
|
||||||
|
400,
|
||||||
|
"input contains no speakable text for the Russian G2P",
|
||||||
|
param="input",
|
||||||
|
code="no_phonemes",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = encode(audio, request.response_format)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("encoding to %s failed", request.response_format)
|
||||||
|
return fail(500, f"encoding to {request.response_format} failed: {exc}", code="encoding_failed")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=payload,
|
||||||
|
media_type=CONTENT_TYPES[request.response_format],
|
||||||
|
headers={"model-id": MODEL_ID, "voice-id": voice},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# response_model=None for the same reason as create_speech above.
|
||||||
|
@app.post("/v1/audio/speech/stream", response_model=None)
|
||||||
|
def stream_speech(request: StreamSpeechRequest) -> Response | JSONResponse:
|
||||||
|
if state["status"] != "ready":
|
||||||
|
return fail(503, f"model is not ready: {state['status']}", code="model_not_ready")
|
||||||
|
|
||||||
|
voice = resolve_voice(request.voice)
|
||||||
|
if voice is None:
|
||||||
|
available = ", ".join(engine.available_voices())
|
||||||
|
return fail(
|
||||||
|
400,
|
||||||
|
f"unknown voice {request.voice!r}; available: {available}",
|
||||||
|
param="voice",
|
||||||
|
code="unknown_voice",
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = engine.iter_audio_chunks(request.input, voice, request.speed or 1.0)
|
||||||
|
try:
|
||||||
|
# Pulled before responding: once the status line is sent it cannot become
|
||||||
|
# a 400, and input with no speakable text has to keep failing that way.
|
||||||
|
first = next(chunks)
|
||||||
|
except StopIteration:
|
||||||
|
return fail(
|
||||||
|
400,
|
||||||
|
"input contains no speakable text for the Russian G2P",
|
||||||
|
param="input",
|
||||||
|
code="no_phonemes",
|
||||||
|
)
|
||||||
|
|
||||||
|
def body() -> Iterator[bytes]:
|
||||||
|
encoder = StreamEncoder(request.response_format)
|
||||||
|
try:
|
||||||
|
encoder.push(first)
|
||||||
|
yield from encoder.drain()
|
||||||
|
for chunk in chunks:
|
||||||
|
encoder.push(chunk)
|
||||||
|
yield from encoder.drain()
|
||||||
|
yield from encoder.finish()
|
||||||
|
except Exception:
|
||||||
|
log.exception("streaming synthesis failed")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
chunks.close()
|
||||||
|
encoder.abort()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
body(),
|
||||||
|
media_type=CONTENT_TYPES[request.response_format],
|
||||||
|
headers={"model-id": MODEL_ID, "voice-id": voice},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
def list_models() -> dict:
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [{"id": MODEL_ID, "object": "model", "created": MODEL_CREATED, "owned_by": "zaakirio"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/voices")
|
||||||
|
def list_voices() -> dict:
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"ready": state["status"] == "ready",
|
||||||
|
"data": [
|
||||||
|
{"id": name, "object": "voice", "checkpoint": VOICE_SPECS[name][0], "gender": VOICE_SPECS[name][1]}
|
||||||
|
for name in engine.available_voices()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz() -> JSONResponse:
|
||||||
|
ready = state["status"] == "ready"
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200 if ready else 503,
|
||||||
|
content={
|
||||||
|
"status": state["status"],
|
||||||
|
"model": MODEL_ID,
|
||||||
|
"voices": engine.available_voices(),
|
||||||
|
"sample_rate": SAMPLE_RATE,
|
||||||
|
"error": state["error"],
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Bake every kokoro-ru asset the server needs into the image.
|
||||||
|
|
||||||
|
Two things make a plain `FROM python` image useless for this model at runtime,
|
||||||
|
and both are fixed here at build time:
|
||||||
|
|
||||||
|
* kokoro-ru's checkpoints and its recompiled espeak-ng data live in the HF
|
||||||
|
cache by default, and the HF cache is part of the disposable container
|
||||||
|
layer, so every `podman run` would re-download ~700 MB.
|
||||||
|
* ruaccent writes its ONNX models, dictionaries and Koziev data into its own
|
||||||
|
`site-packages/ruaccent` directory. It only downloads when those files are
|
||||||
|
missing, so a single `load()` here means the runtime never touches the
|
||||||
|
network.
|
||||||
|
|
||||||
|
RuG2P resolves espeak-data/ and kokoro-config.json relative to ru_g2p.py, so
|
||||||
|
the snapshot layout has to stay flat inside KOKORO_MODEL_DIR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
|
||||||
|
REPO = os.environ.get("KOKORO_RU_REPO", "zaakirio/kokoro-ru")
|
||||||
|
# A commit, not a branch: "main" would silently change the weights under a
|
||||||
|
# rebuild that only touched an unrelated line of the Nix module.
|
||||||
|
REVISION = os.environ.get("KOKORO_RU_REVISION", "main")
|
||||||
|
DEST = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
|
||||||
|
|
||||||
|
VOICES = [v.strip() for v in os.environ.get("KOKORO_RU_VOICES", "sveta,masha,dima").split(",") if v.strip()]
|
||||||
|
|
||||||
|
# sveta and masha share one checkpoint and differ only by voicepack, so the two
|
||||||
|
# female voices cost one 327 MB download, not two.
|
||||||
|
CHECKPOINTS = {
|
||||||
|
"sveta": "kokoro-ru-v2-base.pth",
|
||||||
|
"masha": "kokoro-ru-v2-base.pth",
|
||||||
|
"dima": "kokoro-ru-v2-dima.pth",
|
||||||
|
}
|
||||||
|
|
||||||
|
PATTERNS = [
|
||||||
|
# KModel reads config.json; RuG2P reads kokoro-config.json for the phoneme
|
||||||
|
# vocab. They are not the same file and both are required.
|
||||||
|
"config.json",
|
||||||
|
"kokoro-config.json",
|
||||||
|
"ru_g2p.py",
|
||||||
|
# Stock espeak-ng ru_dict ignores combining-acute stress marks, which is the
|
||||||
|
# one thing this whole front-end exists to fix. The model repo ships a
|
||||||
|
# recompiled dictsource; there is no substitute to fall back to.
|
||||||
|
"espeak-data/**",
|
||||||
|
*(CHECKPOINTS[v] for v in VOICES if v in CHECKPOINTS),
|
||||||
|
*(f"voices/{v}.pt" for v in VOICES),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
|
|
||||||
|
DEST.mkdir(parents=True, exist_ok=True)
|
||||||
|
snapshot_download(
|
||||||
|
repo_id=REPO,
|
||||||
|
revision=REVISION,
|
||||||
|
allow_patterns=PATTERNS,
|
||||||
|
local_dir=str(DEST),
|
||||||
|
)
|
||||||
|
logging.info("kokoro-ru assets in %s at %s", DEST, REVISION)
|
||||||
|
|
||||||
|
missing = [name for name in VOICES if not (DEST / "voices" / f"{name}.pt").exists()]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(f"voice packs missing after download: {missing}")
|
||||||
|
|
||||||
|
# Warm ruaccent into site-packages so `load()` short-circuits at runtime.
|
||||||
|
from ruaccent import RUAccent
|
||||||
|
|
||||||
|
accent = RUAccent()
|
||||||
|
accent.load(omograph_model_size="turbo3.1", use_dictionary=True, tiny_mode=False)
|
||||||
|
logging.info("ruaccent warm: %s", accent.process_all("Здравствуйте, как ваши дела?"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# torch is installed separately in the Dockerfile from the CPU-only index;
|
||||||
|
# do not add it here or pip would pull the ~2.5 GB CUDA build over it.
|
||||||
|
#
|
||||||
|
# ru_g2p.py (from zaakirio/kokoro-ru) imports three things stock kokoro does not
|
||||||
|
# pull on its own: the espeak-ng backend of misaki, ruaccent for stress, and the
|
||||||
|
# phonemizer fork whose EspeakWrapper misaki drives.
|
||||||
|
kokoro==0.9.4
|
||||||
|
misaki[en]>=0.9.4
|
||||||
|
phonemizer-fork
|
||||||
|
espeakng-loader
|
||||||
|
ruaccent
|
||||||
|
|
||||||
|
# kokoro's Albert encoder and ruaccent's ONNX exports both go through
|
||||||
|
# transformers. ru_g2p.py shims token_type_ids for v5, so the floor is what
|
||||||
|
# matters, not the ceiling.
|
||||||
|
transformers>=4.46
|
||||||
|
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
imageio-ffmpeg
|
||||||
|
numpy>=1.26,<3
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
xlib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Open WebUI — self-hosted AI chat UI, deployed here as a UI-client for
|
||||||
|
# external LLM APIs (OpenAI-compatible: OpenAI, OpenRouter, vLLM, LM Studio,
|
||||||
|
# GroqCloud, Mistral, etc.). Runs locally without bundled Ollama.
|
||||||
|
#
|
||||||
|
# Architecture mirrors modules/containers/{3x-ui,tape-rotation}.nix:
|
||||||
|
# - one container, one systemd unit + a root.target
|
||||||
|
# - data on /mnt/services/nodes/<host>/open-webui/data → /app/backend/data
|
||||||
|
# (see AGENTS.md §Подтверждённые инварианты #2 — guard chain is satisfied
|
||||||
|
# because mkServiceStorage already bind-mounts /mnt/services on boot)
|
||||||
|
# - host port bound to 127.0.0.1 only — the only ingress is the nginx
|
||||||
|
# vhost open.zeroq.su (no firewall exception, no public exposure).
|
||||||
|
# Same pattern as 3x-ui.nix:30-31 binding the panel to 127.0.0.1:2049.
|
||||||
|
#
|
||||||
|
# Secrets come from a single sops-encrypted dotenv file
|
||||||
|
# (format = "dotenv", key = "" → whole file). The owner creates the
|
||||||
|
# encrypted file with `sops modules/containers/secrets/open-webui.env`
|
||||||
|
# after filling the .example template next to it.
|
||||||
|
#
|
||||||
|
# Hard requirement (env.py:762 — SystemExit at startup):
|
||||||
|
# WEBUI_SECRET_KEY must be set when WEBUI_AUTH=true.
|
||||||
|
# Generate with: head -c 24 /dev/urandom | base64
|
||||||
|
#
|
||||||
|
# Reverse-proxy requirements (docs.openwebui.com/reference/https):
|
||||||
|
# - WEBUI_URL = public HTTPS URL (OAuth callbacks, internal links)
|
||||||
|
# - CORS_ALLOW_ORIGIN = same public URL (else WebSocket fails silently)
|
||||||
|
# - proxy_buffering off (else SSE streaming breaks markdown)
|
||||||
|
# - proxy_read_timeout ≥ 300s (LLM responses can run minutes)
|
||||||
|
# - WebSocket pass-through (Upgrade / Connection headers)
|
||||||
|
# All of the above are wired into modules/server/nginx.nix:open.zeroq.su.
|
||||||
|
let
|
||||||
|
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/open-webui";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
virtualisation = {
|
||||||
|
podman = {
|
||||||
|
enable = true;
|
||||||
|
autoPrune = {
|
||||||
|
enable = true;
|
||||||
|
flags = [ "--all" ];
|
||||||
|
};
|
||||||
|
dockerCompat = true;
|
||||||
|
};
|
||||||
|
oci-containers = {
|
||||||
|
backend = "podman";
|
||||||
|
containers."open-webui" = {
|
||||||
|
image = "ghcr.io/open-webui/open-webui:main";
|
||||||
|
environment = {
|
||||||
|
TZ = "Europe/Moscow";
|
||||||
|
# Container-internal port (also the upstream default).
|
||||||
|
PORT = "8080";
|
||||||
|
# Required when behind a public HTTPS URL — OAuth callbacks,
|
||||||
|
# share links and internal redirects resolve against this.
|
||||||
|
WEBUI_URL = "https://open.zeroq.su";
|
||||||
|
# Must exactly match WEBUI_URL or WebSocket connections fail
|
||||||
|
# silently (per upstream HTTPS docs). nginx (127.0.0.1) is the
|
||||||
|
# only allowed origin, so a single explicit URL is enough.
|
||||||
|
CORS_ALLOW_ORIGIN = "https://open.zeroq.su";
|
||||||
|
# Honour X-Forwarded-* headers from the reverse proxy.
|
||||||
|
FORWARDED_ALLOW_IPS = "127.0.0.1";
|
||||||
|
# Closed self-hosted: admin creates accounts manually after the
|
||||||
|
# first boot via WEBUI_ADMIN_* from the sops env file.
|
||||||
|
WEBUI_AUTH = "True";
|
||||||
|
ENABLE_SIGNUP = "False";
|
||||||
|
ENABLE_LOGIN_FORM = "True";
|
||||||
|
ENABLE_VERSION_UPDATE_CHECK = "False";
|
||||||
|
# Out of the box Open WebUI phones home to Scarf. The opt-outs
|
||||||
|
# below preserve the previous behaviour from the stub at
|
||||||
|
# modules/server/open-webui.nix (still in tree, commented out in
|
||||||
|
# modules/server/default.nix:41) until that file is removed.
|
||||||
|
ANONYMIZED_TELEMETRY = "False";
|
||||||
|
DO_NOT_TRACK = "True";
|
||||||
|
SCARF_NO_ANALYTICS = "True";
|
||||||
|
# No bundled providers. Owners wire OPENAI_API_KEY /
|
||||||
|
# OPENAI_API_BASE_URL / etc. either via the sops env file
|
||||||
|
# (see sops.secrets."open-webui-env" below) or interactively in
|
||||||
|
# Admin → Settings → Connections once WEBUI_AUTH=true. Empty
|
||||||
|
# base URL is intentional: an empty OPENAI_API_BASE_URL
|
||||||
|
# disables the default /ollama proxy and prevents the container
|
||||||
|
# from probing localhost:11434 on boot.
|
||||||
|
OLLAMA_BASE_URL = "";
|
||||||
|
OPENAI_API_BASE_URL = "";
|
||||||
|
};
|
||||||
|
# Mount the decrypted dotenv only when the sops file exists. Until
|
||||||
|
# the owner creates ./secrets/open-webui.env, the inline environment
|
||||||
|
# is the only source — and the container will refuse to start with
|
||||||
|
# WEBUI_SECRET_KEY="" (env.py:762 — SystemExit). The error message is
|
||||||
|
# the clear signal that the secret needs to be created.
|
||||||
|
# Path comes from the sops block below (`config.sops.secrets.<attr>.path`)
|
||||||
|
# rather than a hardcoded "/run/secrets/<attr>" — see invariant S1
|
||||||
|
# in docs/arch/invariants.md. Guards the sopsFile existence so the
|
||||||
|
# block is optional; the same predicate is what `sops.secrets` uses
|
||||||
|
# to decide whether to declare the attr at all, so `.path` is only
|
||||||
|
# read when the secret actually exists.
|
||||||
|
environmentFiles =
|
||||||
|
lib.optional (builtins.pathExists ./secrets/open-webui.env)
|
||||||
|
config.sops.secrets."open-webui-env".path;
|
||||||
|
volumes = [
|
||||||
|
"${panel}/data:/app/backend/data:rw"
|
||||||
|
];
|
||||||
|
log-driver = "journald";
|
||||||
|
# 127.0.0.1 only — the container is not exposed externally.
|
||||||
|
ports = [ "127.0.0.1:8080:8080/tcp" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Enable container name DNS for all Podman networks (mirrors 3x-ui.nix:120-128).
|
||||||
|
networking.firewall.interfaces =
|
||||||
|
let
|
||||||
|
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
"${matchAll}".allowedUDPPorts = [ 53 ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd = {
|
||||||
|
services = {
|
||||||
|
"podman-open-webui" = {
|
||||||
|
serviceConfig.Restart = lib.mkOverride 90 "always";
|
||||||
|
partOf = [ "podman-compose-open-webui-root.target" ];
|
||||||
|
wantedBy = [ "podman-compose-open-webui-root.target" ];
|
||||||
|
};
|
||||||
|
"podman-update-open-webui" = {
|
||||||
|
path = [ pkgs.podman ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
TimeoutSec = 300;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
podman pull ghcr.io/open-webui/open-webui:main
|
||||||
|
systemctl restart podman-open-webui.service
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# Starts/stops together with the open-webui container.
|
||||||
|
targets."podman-compose-open-webui-root" = {
|
||||||
|
unitConfig.Description = "Root target for open-webui.";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
};
|
||||||
|
# Enable automatic image updates:
|
||||||
|
# systemd.timers."podman-update-open-webui" = {
|
||||||
|
# wantedBy = [ "timers.target" ];
|
||||||
|
# timerConfig = {
|
||||||
|
# OnCalendar = "weekly";
|
||||||
|
# Persistent = true;
|
||||||
|
# };
|
||||||
|
# };
|
||||||
|
tmpfiles.rules = [
|
||||||
|
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
|
||||||
|
"root"
|
||||||
|
"root"
|
||||||
|
)
|
||||||
|
(xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${panel}/data" "0755" "root" "root")
|
||||||
|
# Relabel panel dir for SELinux so containers can access it.
|
||||||
|
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# sops secret is declared only when the encrypted file actually exists,
|
||||||
|
# so the flake still evaluates (and rebuilds apply) on a host that hasn't
|
||||||
|
# created the secret yet. Once ./secrets/open-webui.env is created and
|
||||||
|
# encrypted with `sops modules/containers/secrets/open-webui.env`, this
|
||||||
|
# condition becomes true and the secret is wired in.
|
||||||
|
#
|
||||||
|
# Hard requirement (env.py:762 — SystemExit at startup):
|
||||||
|
# WEBUI_SECRET_KEY must be present in the env file when WEBUI_AUTH=true.
|
||||||
|
sops.secrets = lib.optionalAttrs (builtins.pathExists ./secrets/open-webui.env) {
|
||||||
|
"open-webui-env" = {
|
||||||
|
# key = "" → decrypt the whole file, not a single key.
|
||||||
|
# format = "dotenv" → the file IS one .env ready for environmentFiles:
|
||||||
|
# every non-comment KEY=VALUE line lands in the container environment.
|
||||||
|
# After this module is wired the file is mounted at
|
||||||
|
# /run/secrets/open-webui-env (sops-nix default for this attr name).
|
||||||
|
key = "";
|
||||||
|
format = "dotenv";
|
||||||
|
sopsFile = ./secrets/open-webui.env;
|
||||||
|
mode = "0400";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,15 @@
|
|||||||
xlib,
|
xlib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
let
|
||||||
|
# Composite env file path shared by the generator
|
||||||
|
# (remnawave-env.service below) and the container's `environmentFiles`.
|
||||||
|
# Lifting to a single binding prevents the two copies from drifting
|
||||||
|
# apart in future edits — see invariant S1 in docs/arch/invariants.md.
|
||||||
|
# Note: this is NOT a sops materialization (it's written by a oneshot),
|
||||||
|
# so `config.sops.secrets.<...>.path` is not the right primitive here.
|
||||||
|
envFile = "/run/secrets/remnawave-env";
|
||||||
|
in
|
||||||
{
|
{
|
||||||
# Runtime
|
# Runtime
|
||||||
virtualisation.podman = {
|
virtualisation.podman = {
|
||||||
@@ -58,7 +67,7 @@
|
|||||||
# "WEBHOOK_URL" = "https://your-webhook-url.com/endpoint";
|
# "WEBHOOK_URL" = "https://your-webhook-url.com/endpoint";
|
||||||
};
|
};
|
||||||
environmentFiles = [
|
environmentFiles = [
|
||||||
"/run/secrets/remnawave-env"
|
envFile
|
||||||
];
|
];
|
||||||
ports = [
|
ports = [
|
||||||
"3003:3003/tcp"
|
"3003:3003/tcp"
|
||||||
@@ -126,14 +135,14 @@
|
|||||||
User = "root";
|
User = "root";
|
||||||
};
|
};
|
||||||
script = ''
|
script = ''
|
||||||
cat > /run/secrets/remnawave-env <<EOF
|
cat > ${envFile} <<EOF
|
||||||
DATABASE_URL=$(cat ${config.sops.secrets.DATABASE_URL.path})
|
DATABASE_URL=$(cat ${config.sops.secrets.DATABASE_URL.path})
|
||||||
DATABASE_PASSWORD=$(cat ${config.sops.secrets.DATABASE_PASSWORD.path})
|
DATABASE_PASSWORD=$(cat ${config.sops.secrets.DATABASE_PASSWORD.path})
|
||||||
JWT_AUTH_SECRET=$(cat ${config.sops.secrets.JWT_AUTH_SECRET.path})
|
JWT_AUTH_SECRET=$(cat ${config.sops.secrets.JWT_AUTH_SECRET.path})
|
||||||
JWT_API_TOKENS_SECRET=$(cat ${config.sops.secrets.JWT_API_TOKENS_SECRET.path})
|
JWT_API_TOKENS_SECRET=$(cat ${config.sops.secrets.JWT_API_TOKENS_SECRET.path})
|
||||||
WEBHOOK_SECRET_HEADER=$(cat ${config.sops.secrets.WEBHOOK_SECRET_HEADER.path})
|
WEBHOOK_SECRET_HEADER=$(cat ${config.sops.secrets.WEBHOOK_SECRET_HEADER.path})
|
||||||
EOF
|
EOF
|
||||||
chmod 600 /run/secrets/remnawave-env
|
chmod 600 ${envFile}
|
||||||
'';
|
'';
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
WEBUI_SECRET_KEY=ENC[AES256_GCM,data:l6USiQmMkMz/zniIebT35HfXxZI8qrhe6Cdl8hpT98c=,iv:Po9bova4dfiykl+ckH4v6DqzSJOgULx7ro3kXMFRvFI=,tag:7jurD14N7QDRHX5ruFDEeQ==,type:str]
|
||||||
|
WEBUI_ADMIN_EMAIL=ENC[AES256_GCM,data:EZgNXSpbpROz3TZRLaSQTQ==,iv:i98kChemam9nB3iCMwCTRYB69b2eUBy6QCoeZ3AjAP0=,tag:INnB1Zp9VA6M//yyAhRh3A==,type:str]
|
||||||
|
WEBUI_ADMIN_NAME=ENC[AES256_GCM,data:8l8dJ85p,iv:LltveatNlX4FEGmxhtYLYmviIvLK0xSMuVsk9DRRglw=,tag:OrTlnOLlQe03hoYTkaPotg==,type:str]
|
||||||
|
WEBUI_ADMIN_PASSWORD=ENC[AES256_GCM,data:PKfZQHiAa96vcGUCGigjXQ==,iv:WLb1mgCV3IJHnHcBvf4yAPiautgXqCC2L2bzt6i0t7U=,tag:nw78tvyUOYmGMunBwvIr+A==,type:str]
|
||||||
|
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4d3pNVlZEQS85d2R3WUZX\nKy9iOFZ4MjU2UkQwVHdobTlBY3l0MldONWlvCnU5dllobmtLQXlMM28xN0FSTmxD\nNnhmZVRwdnpaZ3NkZDVCWERBckZiQjgKLS0tIFBPeXZMNXRjZ3pBQUlndXB5MTBB\nMVdhSGJvZkE2VzZiZ2VxL0RKTDJ2aDQKsxlibeAoO74411VemXT+8UBG0JdemgHD\nVONIEp/VsbEJDWgDfSGhLaH4KN2hTsCtyhdkCU0FohgWB+xWyJz6MA==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
|
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
|
||||||
|
sops_lastmodified=2026-10-07T09:32:44Z
|
||||||
|
sops_mac=ENC[AES256_GCM,data:SSHgEEc3u2Zf13q5W4LD7bkrVlQTzLIYiZWXhBiDS6CjC4fUZcJq99OTSTNixzqpxSdnjeRtmzA6d6vGNfxvEOmsE1f4hBNm9ps0RHU4yLfr5vQG9Ff973uDwDU+JMqP3aMU+xpUuPkhW0zRpeST+w0thuPtjzhR2z/A2yPvYzU=,iv:5/cfD51eK0R9cGsr4wZu6CnwEdMjP0CYj3CM7+X4XQg=,tag:1FRcAULHG+XzmRiTMK8aWQ==,type:str]
|
||||||
|
sops_unencrypted_suffix=_unencrypted
|
||||||
|
sops_version=3.13.3
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
JWT_SECRET=ENC[AES256_GCM,data:+ut+3v4KrckbDT5m85JhQd8x2ayF55Uy5FiEw8qTJoBgz7Zz+HprhxPB09RDd6CX11SrcsDcf6vW3wOE7IdeQA==,iv:c3GqspoQH2+2NQvsqip3bs4XW1PWSZtK+l7HzE83Qj8=,tag:hDqFgPa8gYLqPeA0svGWfw==,type:str]
|
||||||
|
ADMIN_PASSWORD=ENC[AES256_GCM,data:UxcSEO7opTme9DR4XM3/FQ==,iv:nSpFt7rVp0K+hAc3aAoorw5XDMNK0V+zeBw4GHwTsOs=,tag:fQ1u/WtlVfEhc7fZnLnboQ==,type:str]
|
||||||
|
APP_URL=ENC[AES256_GCM,data:OJAv0C1DHYbFltgXmcSG/s97Lf3qJovkcEsPA9Xr,iv:Fw9Mh/+dYgah+/OWPBtRRXkO42KXWvKIuylyXfcaRK8=,tag:a6A8xS9aRyjvEfZvSxgMuQ==,type:str]
|
||||||
|
CORS_ORIGINS=ENC[AES256_GCM,data:z4MvIbQcvk+aUaME/llV7rWaDtCFYIT0nVGtlD8j,iv:oAL5NcWOfez+vVbKoibUIOagePROKW+4QV81sK+Cets=,tag:+QftIwvmTADEFMEz+ZCh4A==,type:str]
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=ENC[AES256_GCM,data:QnI=,iv:PQwsVoOnLmTrnpUTaQAEOX3VG2hTLm/qnZjwIOL9kac=,tag:SZxCnlr0qTxH65bZ53rvQQ==,type:str]
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=ENC[AES256_GCM,data:TaHhXO7WVUzywpUxTr5l+IG7QfXY,iv:gLqOSZBBzC9v/BT+r+ENLjApTmLsra2jDbenJLHn4AY=,tag:HCmGtfnVIjDktQpTtUbc9A==,type:str]
|
||||||
|
NOTIFY_EMAIL=
|
||||||
|
TAPE_LABEL_REGEX=
|
||||||
|
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnb283UjRSRU1SYjBxZWlz\nOWw2cXM2TTU2QmdWUG5nUU9vWVZhUDZoelJRCi9McmV0Q05hNVpXSllsbUYwdkEw\nTnU3OWRCcFhrQzg4blhuRFJjdDVkUFkKLS0tIEt4Nzc2ZWtOL1VQQzVObzZWYURu\nWFUwVWp5OUhDME0xVlBRS3psdVBSd0EKsy77QR7CveXQdKlo+JeNSaNpnUh//AoP\nV+hbUSIj05Ws20rr9uk8uTDnjnc91r2vxGWxznXf6M9putZfARBdfQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
|
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
|
||||||
|
sops_lastmodified=2026-09-24T13:11:03Z
|
||||||
|
sops_mac=ENC[AES256_GCM,data:xJO2u9jQM8XiVYekVvwN+iv3megGpf80F1ANib9Kro/kgvTQUZU14jmku8OjfRtjrFsM9b/cBr+ml0Z+MSknmwtR4D3mfRIa0yFfqmS/VZJs8SrH2+c/a8kYGhDNcWgAbx+u/tZB8q0QrQsbDYmN8yvsNwWi2ixMLKlv2thPIbA=,iv:CEgU5499Gr0gD+M5iSYJ315r7RU9RWKKapXywVCQivo=,tag:9YTO7K/zsINSOXfR6PaG8A==,type:str]
|
||||||
|
sops_unencrypted_suffix=_unencrypted
|
||||||
|
sops_version=3.13.3
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
xlib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# TapeRotation — web app for tracking and rotation of backup tape
|
||||||
|
# cartridges. https://github.com/ElizarovEugene/TapeRotation
|
||||||
|
#
|
||||||
|
# Podman adaptation of the upstream docker-compose deployment. Two
|
||||||
|
# containers on a shared "taperotation_default" network (mirrors the
|
||||||
|
# compose project network):
|
||||||
|
# - taperotation-backend: FastAPI/uvicorn on :8001, SQLite at /data,
|
||||||
|
# file attachments at /app/uploads
|
||||||
|
# - taperotation-frontend: nginx serving the built React app on :80,
|
||||||
|
# proxying /api to http://backend:8001
|
||||||
|
# The backend container gets a static IP on the shared network and the
|
||||||
|
# frontend maps "backend" → that IP via --add-host, because this host's
|
||||||
|
# CoreDNS service owns port 53 on every interface: the podman network DNS
|
||||||
|
# plugin (aardvark-dns) cannot bind on the network gateway, so a network
|
||||||
|
# with dns_enabled would refuse to attach containers.
|
||||||
|
#
|
||||||
|
# Published host port 5174 → container:80 for the web UI. Keep it out
|
||||||
|
# of networking.firewall like the other panel ports and front it with an
|
||||||
|
# nginx vhost, e.g. in server/nginx.nix:
|
||||||
|
# { domain = "tape-rotation.zeroq.su"; port = 5174; }
|
||||||
|
# and set APP_URL / CORS_ORIGINS in the sops-encrypted env file.
|
||||||
|
#
|
||||||
|
# Instance config lives in one sops-encrypted .env file (mirrors the
|
||||||
|
# upstream .env.example, sops-nix format = "dotenv", key = "" → whole
|
||||||
|
# file): sops modules/containers/secrets/tape-rotation.env
|
||||||
|
# On first boot the admin account is created from ADMIN_USERNAME /
|
||||||
|
# ADMIN_PASSWORD from that file.
|
||||||
|
let
|
||||||
|
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/tape-rotation";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
virtualisation = {
|
||||||
|
podman = {
|
||||||
|
enable = true;
|
||||||
|
autoPrune = {
|
||||||
|
enable = true;
|
||||||
|
flags = [ "--all" ];
|
||||||
|
};
|
||||||
|
dockerCompat = true;
|
||||||
|
};
|
||||||
|
oci-containers = {
|
||||||
|
backend = "podman";
|
||||||
|
containers = {
|
||||||
|
"taperotation-backend" = {
|
||||||
|
image = "docker.io/elizaroveugene/taperotation-backend:latest";
|
||||||
|
environment = {
|
||||||
|
"DATABASE_URL" = "sqlite:////data/taperotation.db";
|
||||||
|
"JWT_EXPIRE_MINUTES" = "480";
|
||||||
|
"ADMIN_USERNAME" = "admin";
|
||||||
|
"ADMIN_LANGUAGE" = "en";
|
||||||
|
"NOTIFY_DAYS_BEFORE" = "7";
|
||||||
|
"TZ" = "Europe/Moscow";
|
||||||
|
};
|
||||||
|
# Path resolved from the sops block at the bottom of this file —
|
||||||
|
# see invariant S1 in docs/arch/invariants.md.
|
||||||
|
environmentFiles = [ config.sops.secrets."tape-rotation-env".path ];
|
||||||
|
volumes = [
|
||||||
|
"${panel}/db:/data:rw"
|
||||||
|
"${panel}/uploads:/app/uploads:rw"
|
||||||
|
];
|
||||||
|
log-driver = "journald";
|
||||||
|
extraOptions = [
|
||||||
|
"--network=taperotation_default"
|
||||||
|
# Static IP the frontend reaches "backend" at (see --add-host
|
||||||
|
# in the frontend container; network DNS is disabled).
|
||||||
|
"--ip=10.89.0.10"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"taperotation-frontend" = {
|
||||||
|
image = "docker.io/elizaroveugene/taperotation-frontend:latest";
|
||||||
|
ports = [
|
||||||
|
"0.0.0.0:5174:80/tcp"
|
||||||
|
];
|
||||||
|
log-driver = "journald";
|
||||||
|
extraOptions = [
|
||||||
|
"--network=taperotation_default"
|
||||||
|
# Baked-in nginx upstream is http://backend:8001; resolve it via
|
||||||
|
# /etc/hosts since the network has no DNS plugin.
|
||||||
|
"--add-host=backend:10.89.0.10"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Enable container name DNS for all Podman networks.
|
||||||
|
networking.firewall.interfaces =
|
||||||
|
let
|
||||||
|
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
"${matchAll}".allowedUDPPorts = [ 53 ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd = {
|
||||||
|
services = {
|
||||||
|
"podman-taperotation-backend" = {
|
||||||
|
serviceConfig.Restart = lib.mkOverride 90 "always";
|
||||||
|
after = [ "podman-network-taperotation_default.service" ];
|
||||||
|
requires = [ "podman-network-taperotation_default.service" ];
|
||||||
|
partOf = [ "podman-compose-tape-rotation-root.target" ];
|
||||||
|
wantedBy = [ "podman-compose-tape-rotation-root.target" ];
|
||||||
|
};
|
||||||
|
"podman-taperotation-frontend" = {
|
||||||
|
serviceConfig.Restart = lib.mkOverride 90 "always";
|
||||||
|
after = [
|
||||||
|
"podman-network-taperotation_default.service"
|
||||||
|
"podman-taperotation-backend.service"
|
||||||
|
];
|
||||||
|
requires = [ "podman-network-taperotation_default.service" ];
|
||||||
|
partOf = [ "podman-compose-tape-rotation-root.target" ];
|
||||||
|
wantedBy = [ "podman-compose-tape-rotation-root.target" ];
|
||||||
|
};
|
||||||
|
"podman-network-taperotation_default" = {
|
||||||
|
path = [ pkgs.podman ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
ExecStop = "podman network rm -f taperotation_default";
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
# Always (re)create the stack network: the host's CoreDNS owns :53
|
||||||
|
# on every interface, so the network DNS plugin (aardvark-dns)
|
||||||
|
# can't bind on the gateway → --disable-dns. --subnet backs the
|
||||||
|
# backend's static IP. Recreate-on-start also self-heals after a
|
||||||
|
# `podman system prune` removed the (temporarily unused) network.
|
||||||
|
podman network rm -f taperotation_default >/dev/null 2>&1 || true
|
||||||
|
podman network create --disable-dns --subnet=10.89.0.0/24 taperotation_default
|
||||||
|
'';
|
||||||
|
partOf = [ "podman-compose-tape-rotation-root.target" ];
|
||||||
|
wantedBy = [ "podman-compose-tape-rotation-root.target" ];
|
||||||
|
};
|
||||||
|
"podman-update-taperotation" = {
|
||||||
|
path = [ pkgs.podman ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
TimeoutSec = 300;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
podman pull docker.io/elizaroveugene/taperotation-backend:latest
|
||||||
|
podman pull docker.io/elizaroveugene/taperotation-frontend:latest
|
||||||
|
systemctl restart podman-taperotation-backend.service podman-taperotation-frontend.service
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# Starts/stops together with all TapeRotation containers.
|
||||||
|
targets."podman-compose-tape-rotation-root" = {
|
||||||
|
unitConfig.Description = "Root target generated by compose2nix.";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
};
|
||||||
|
# Enable automatic image updates:
|
||||||
|
# systemd.timers."podman-update-taperotation" = {
|
||||||
|
# wantedBy = [ "timers.target" ];
|
||||||
|
# timerConfig = {
|
||||||
|
# OnCalendar = "weekly";
|
||||||
|
# Persistent = true;
|
||||||
|
# };
|
||||||
|
# };
|
||||||
|
tmpfiles.rules = [
|
||||||
|
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
|
||||||
|
"root"
|
||||||
|
"root"
|
||||||
|
)
|
||||||
|
(xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${panel}/db" "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${panel}/uploads" "0755" "root" "root")
|
||||||
|
# Relabel panel dir for SELinux so containers can access it.
|
||||||
|
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
sops.secrets."tape-rotation-env" = {
|
||||||
|
# key = "" → decrypt the whole file, not a single key.
|
||||||
|
# format = "dotenv" → the file IS one .env ready for environmentFiles:
|
||||||
|
# every non-comment KEY=VALUE line lands in the container environment.
|
||||||
|
key = "";
|
||||||
|
format = "dotenv";
|
||||||
|
sopsFile = ./secrets/tape-rotation.env;
|
||||||
|
mode = "0400";
|
||||||
|
};
|
||||||
|
}
|
||||||
+9
-23
@@ -3,24 +3,15 @@ let
|
|||||||
# NixOS-only modules. termux runs nix-on-droid (its own module system,
|
# NixOS-only modules. termux runs nix-on-droid (its own module system,
|
||||||
# class = "nixOnDroid"): options like services.*, users.*, sops.*, disko.*
|
# class = "nixOnDroid"): options like services.*, users.*, sops.*, disko.*
|
||||||
# and nixpkgs.overlays (flake assertion) do not exist there.
|
# and nixpkgs.overlays (flake assertion) do not exist there.
|
||||||
moduleArgs = config: {
|
#
|
||||||
inherit inputs;
|
# `xlib` arrives as a module argument (see lib/mkSystem.nix) and is plain
|
||||||
xlib = config.xlib;
|
# data, not a module option, so nothing here has to declare or set it.
|
||||||
};
|
|
||||||
defaultModule =
|
defaultModule =
|
||||||
{
|
{
|
||||||
config,
|
|
||||||
deviceType,
|
|
||||||
lib,
|
lib,
|
||||||
xlib,
|
xlib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
|
||||||
isDesktop = builtins.elem deviceType [
|
|
||||||
"primary"
|
|
||||||
"secondary"
|
|
||||||
];
|
|
||||||
in
|
|
||||||
{
|
{
|
||||||
imports =
|
imports =
|
||||||
with inputs;
|
with inputs;
|
||||||
@@ -37,33 +28,28 @@ let
|
|||||||
self.homeConfigurations.default.nixosModule # default homeConfigurations
|
self.homeConfigurations.default.nixosModule # default homeConfigurations
|
||||||
disko.nixosModules.disko # disko module
|
disko.nixosModules.disko # disko module
|
||||||
]
|
]
|
||||||
++ lib.optional isDesktop ./desktop # desktop class: primary/secondary
|
# desktop class: primary/secondary
|
||||||
|
++ lib.optional xlib.isDesktop ./desktop
|
||||||
# device-type module dir; "minimal" has no extra modules
|
# device-type module dir; "minimal" has no extra modules
|
||||||
++ lib.optional (!isDesktop && deviceType != "minimal") (./. + "/${deviceType}");
|
++ lib.optional (!xlib.isDesktop && xlib.device.type != "minimal") (./. + "/${xlib.device.type}");
|
||||||
nixpkgs.overlays = with inputs; [
|
nixpkgs.overlays = with inputs; [
|
||||||
self.nixosOverlays.default
|
self.nixosOverlays.default
|
||||||
];
|
];
|
||||||
networking.hostName = lib.mkDefault config.xlib.device.hostname;
|
networking.hostName = lib.mkDefault xlib.device.hostname;
|
||||||
_module.args = moduleArgs config;
|
|
||||||
};
|
};
|
||||||
strictModule =
|
strictModule =
|
||||||
{
|
{
|
||||||
config,
|
|
||||||
deviceType,
|
|
||||||
lib,
|
|
||||||
xlib,
|
xlib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
imports = with inputs; [
|
imports = [
|
||||||
# ./essentials
|
# ./essentials
|
||||||
# ./users.nix
|
# ./users.nix
|
||||||
./options.nix
|
./options.nix
|
||||||
(./. + "/${deviceType}")
|
(./. + "/${xlib.device.type}")
|
||||||
# sops-nix.nixosModules.sops
|
# sops-nix.nixosModules.sops
|
||||||
];
|
];
|
||||||
|
|
||||||
_module.args = moduleArgs config;
|
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
config,
|
config,
|
||||||
pkgs,
|
pkgs,
|
||||||
inputs,
|
inputs,
|
||||||
|
xlib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
@@ -102,6 +103,7 @@
|
|||||||
net-tools
|
net-tools
|
||||||
usbtree
|
usbtree
|
||||||
iperf3
|
iperf3
|
||||||
|
glow
|
||||||
# lazydocker
|
# lazydocker
|
||||||
# dtop
|
# dtop
|
||||||
# framework-tool-tui
|
# framework-tool-tui
|
||||||
@@ -185,7 +187,7 @@
|
|||||||
enable = true;
|
enable = true;
|
||||||
config = {
|
config = {
|
||||||
user = {
|
user = {
|
||||||
name = "oqyude";
|
name = xlib.device.username;
|
||||||
email = "oqyude@gmail.com";
|
email = "oqyude@gmail.com";
|
||||||
};
|
};
|
||||||
pull = {
|
pull = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
pkgs,
|
pkgs,
|
||||||
|
xlib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
};
|
};
|
||||||
shellInit = ''
|
shellInit = ''
|
||||||
beet-p() {
|
beet-p() {
|
||||||
local base="/home/oqyude/.config/beets/My"
|
local base="${xlib.dirs.user-home}/.config/beets/My"
|
||||||
local rel
|
local rel
|
||||||
rel=$(realpath --relative-to="$base" "$PWD")
|
rel=$(realpath --relative-to="$base" "$PWD")
|
||||||
beet mod "path:$rel" playlist="$*"
|
beet mod "path:$rel" playlist="$*"
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
beet im ./ -S $*
|
beet im ./ -S $*
|
||||||
}
|
}
|
||||||
beet-path() {
|
beet-path() {
|
||||||
realpath --relative-to="/home/oqyude/.config/beets/My" "$1"
|
realpath --relative-to="${xlib.dirs.user-home}/.config/beets/My" "$1"
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
shellAliases = {
|
shellAliases = {
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
gc = "git add . && git commit -m 'dev: автокоммит $(date +'%Y-%m-%d %H:%M:%S')'";
|
gc = "git add . && git commit -m 'dev: автокоммит $(date +'%Y-%m-%d %H:%M:%S')'";
|
||||||
y = "yazi";
|
y = "yazi";
|
||||||
nix-shellp = "nix-shell --run $SHELL -p";
|
nix-shellp = "nix-shell --run $SHELL -p";
|
||||||
beet-path-library = "realpath --relative-to='/home/oqyude/.config/beets/My' .";
|
beet-path-library = "realpath --relative-to='${xlib.dirs.user-home}/.config/beets/My' .";
|
||||||
z-proxy = "export ALL_PROXY=socks5://localhost:10808";
|
z-proxy = "export ALL_PROXY=socks5://localhost:10808";
|
||||||
zh-proxy = "export HTTPS_PROXY=http://localhost:10808 && export HTTP_PROXY=http://localhost:10808";
|
zh-proxy = "export HTTPS_PROXY=http://localhost:10808 && export HTTP_PROXY=http://localhost:10808";
|
||||||
|
|
||||||
@@ -62,6 +63,29 @@
|
|||||||
z-p-1 = "ssh pubray-1";
|
z-p-1 = "ssh pubray-1";
|
||||||
z-map-local-proxy = "ssh -R 10808:localhost:10808";
|
z-map-local-proxy = "ssh -R 10808:localhost:10808";
|
||||||
|
|
||||||
|
# authelia — Argon2id hash with the exact params configured for the
|
||||||
|
# authelia daemon (memory=65536, iterations=3, parallelism=4,
|
||||||
|
# salt-length=16). Defaults already match, but explicit so an upstream
|
||||||
|
# default change can't silently produce a hash the daemon rejects.
|
||||||
|
#
|
||||||
|
# Note: the old `authelia hash-password --argon2id` form is from
|
||||||
|
# Authelia ≤4.35. Current CLI is `crypto hash generate argon2
|
||||||
|
# -v argon2id`. The subcommand takes no positional password — it
|
||||||
|
# prompts with confirmation (omit confirmation with `--no-confirm`)
|
||||||
|
# or accepts `--password <value>`.
|
||||||
|
#
|
||||||
|
# Example usage:
|
||||||
|
# authelia-hash
|
||||||
|
# # interactive prompt (twice — confirmation), then hash on stdout
|
||||||
|
# authelia-hash --no-confirm
|
||||||
|
# # single prompt, hash on stdout
|
||||||
|
# authelia-hash --no-confirm --password 'mypassword'
|
||||||
|
# # non-interactive (for scripts)
|
||||||
|
#
|
||||||
|
# Output goes to stdout — append it to users_database.yml by hand:
|
||||||
|
# authelia-hash --no-confirm >> /mnt/services/authelia/users_database.yml
|
||||||
|
authelia-hash = "authelia crypto hash generate argon2 -v argon2id -i 3 -m 65536 -p 4 -s 16";
|
||||||
|
|
||||||
# Somethings
|
# Somethings
|
||||||
reboot-bios = "sudo systemctl reboot --firmware-setup";
|
reboot-bios = "sudo systemctl reboot --firmware-setup";
|
||||||
|
|
||||||
|
|||||||
+25
-15
@@ -3,21 +3,31 @@
|
|||||||
lib,
|
lib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
lib.mkIf config.xlib.ssh.enable {
|
{
|
||||||
services.openssh = {
|
options.host.ssh = {
|
||||||
enable = true;
|
enable = lib.mkOption {
|
||||||
allowSFTP = true;
|
type = lib.types.bool;
|
||||||
openFirewall = lib.mkDefault false;
|
default = false;
|
||||||
hostKeys = [
|
description = "Enable the SSH server with the shared config below.";
|
||||||
{
|
};
|
||||||
path = "/etc/ssh/id_ed25519";
|
};
|
||||||
type = "ed25519";
|
|
||||||
}
|
config = lib.mkIf config.host.ssh.enable {
|
||||||
];
|
services.openssh = {
|
||||||
settings = {
|
enable = true;
|
||||||
PasswordAuthentication = false;
|
allowSFTP = true;
|
||||||
PermitRootLogin = "yes";
|
openFirewall = lib.mkDefault false;
|
||||||
UsePAM = true;
|
hostKeys = [
|
||||||
|
{
|
||||||
|
path = "/etc/ssh/id_ed25519";
|
||||||
|
type = "ed25519";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
settings = {
|
||||||
|
PasswordAuthentication = false;
|
||||||
|
PermitRootLogin = "yes";
|
||||||
|
UsePAM = true;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
description = "Prebuild NixOS closure";
|
description = "Prebuild NixOS closure";
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
CPUQuota = "20%";
|
CPUQuota = "20%";
|
||||||
User = "oqyude";
|
User = xlib.device.username;
|
||||||
Group = "users";
|
Group = "users";
|
||||||
Nice = 10;
|
Nice = 10;
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
WorkingDirectory = "/tmp";
|
WorkingDirectory = "/tmp";
|
||||||
Environment = [
|
Environment = [
|
||||||
"HOME=/home/oqyude"
|
"HOME=${xlib.dirs.user-home}"
|
||||||
];
|
];
|
||||||
ExecStart = ''
|
ExecStart = ''
|
||||||
${pkgs.nix}/bin/nix build --no-link /etc/nixos#nixosConfigurations.${config.networking.hostName}.config.system.build.toplevel
|
${pkgs.nix}/bin/nix build --no-link /etc/nixos#nixosConfigurations.${config.networking.hostName}.config.system.build.toplevel
|
||||||
|
|||||||
+68
-101
@@ -1,109 +1,76 @@
|
|||||||
{
|
{
|
||||||
config,
|
|
||||||
lib,
|
lib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
# Cross-module options: declared here, not in the module that reads them.
|
||||||
# Option factory for the xlib.dirs namespace
|
#
|
||||||
mkDir =
|
# An option belongs in this file when at least one context *sets* it while
|
||||||
default: description:
|
# another module *reads* it — the reader cannot be the only place that knows
|
||||||
lib.mkOption {
|
# the option exists. `modules/essentials/ssh.nix` does not belong here: it
|
||||||
type = lib.types.str;
|
# declares and reads `host.ssh.enable` itself, within one module.
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
|
|
||||||
helpers = import ../lib/xlib.nix { inherit lib; };
|
|
||||||
in
|
|
||||||
{
|
{
|
||||||
options = {
|
# Remote-builder wiring. A coordinator (e.g. sapphira) sets
|
||||||
xlib = {
|
# `host.builder.clients` to register remote build machines;
|
||||||
device = {
|
# a builder host (e.g. the WSL on vetymae) sets `host.builder.enable`
|
||||||
type = lib.mkOption {
|
# to advertise itself. The two halves are intentionally split so a single
|
||||||
type = lib.types.enum [
|
# declaration in configurations/* is enough to flip each side.
|
||||||
"minimal"
|
options.host.builder = {
|
||||||
"primary"
|
enable = lib.mkOption {
|
||||||
"secondary"
|
type = lib.types.bool;
|
||||||
"server"
|
default = false;
|
||||||
"vds"
|
description = ''
|
||||||
"wsl"
|
Advertise this host as a remote Nix builder and accept builds
|
||||||
"termux"
|
from other machines in the flake over SSH.
|
||||||
];
|
'';
|
||||||
default = "minimal";
|
};
|
||||||
description = "Type of device for this host.";
|
clients = lib.mkOption {
|
||||||
};
|
type = lib.types.listOf lib.types.attrs;
|
||||||
username = lib.mkOption {
|
default = [ ];
|
||||||
type = lib.types.str;
|
description = ''
|
||||||
default = "oqyude";
|
List of remote Nix build machines this coordinator should
|
||||||
description = "Username for host.";
|
register via `nix.buildMachines`. Each entry matches the NixOS
|
||||||
};
|
option schema (hostName, sshUser, sshKey, systems,
|
||||||
hostname = lib.mkOption {
|
supportedFeatures, ...). Two extra attributes are consumed by
|
||||||
type = lib.types.str;
|
modules/server/builder.nix and stripped before reaching
|
||||||
default = "nixos";
|
`nix.buildMachines`:
|
||||||
description = "Hostname...";
|
- `proxyCommand` — generates a per-builder Host block in the
|
||||||
};
|
system-wide OpenSSH config (the nix-daemon runs as root and
|
||||||
};
|
cannot see the user's ~/.ssh/config).
|
||||||
ssh = {
|
- `hostKeyAlias` — alias used inside that SSH matchBlock.
|
||||||
enable = lib.mkOption {
|
A builder reachable on its own (no ProxyCommand needed) omits
|
||||||
type = lib.types.bool;
|
both and gets no SSH matchBlock. Empty by default — opt in by
|
||||||
default = false;
|
setting this list.
|
||||||
description = "Enable SSH server with the standard config.";
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
dirs = {
|
|
||||||
user-home = mkDir "/home/${config.xlib.device.username}" "User home directory.";
|
options.host."3x-ui" = {
|
||||||
user-storage = mkDir "${config.xlib.dirs.user-home}/Storage" "User storage directory.";
|
# Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/)
|
||||||
archive-drive = mkDir "/mnt/archive" "Archive drive mount point.";
|
# gets mounted read-only into the 3x-ui container so the panel
|
||||||
lamet-drive = mkDir "/mnt/lamet" "Lamet drive mount point.";
|
# can terminate TLS itself. Set null if 3x-ui serves plain HTTP
|
||||||
mobile-drive = mkDir "/mnt/mobile" "Mobile drive mount point.";
|
# and TLS is terminated by an upstream nginx.
|
||||||
therima-drive = mkDir "/mnt/therima" "Therima drive mount point.";
|
certDomain = lib.mkOption {
|
||||||
vetymae-drive = mkDir "/mnt/vetymae" "Vetymae drive mount point.";
|
type = lib.types.nullOr lib.types.str;
|
||||||
soptur-drive = mkDir "/mnt/soptur" "Soptur drive mount point.";
|
default = null;
|
||||||
wsl-home = mkDir "/mnt/c/Users/${config.xlib.device.username}" "WSL home directory.";
|
example = "pubray1.zeroq.su";
|
||||||
wsl-storage = mkDir "${config.xlib.dirs.wsl-home}/Storage" "WSL storage directory.";
|
description = ''
|
||||||
server-home = mkDir "/home/${config.xlib.device.username}/External" "Server home directory.";
|
Domain whose LE cert should be mounted into the 3x-ui
|
||||||
server-credentials = mkDir "${config.xlib.dirs.server-home}/Credentials/server" "Server credentials directory.";
|
container at /root/cert/fullchain.pem and key.pem.
|
||||||
storage = mkDir "${config.xlib.dirs.server-home}/Storage" "General storage directory.";
|
'';
|
||||||
calibre-library = mkDir "${config.xlib.dirs.server-home}/Books-Library" "Calibre library directory.";
|
};
|
||||||
music-library = mkDir "${config.xlib.dirs.user-home}/Music" "Music library directory.";
|
# Publish host:15380 → container:443. Only nodes that host an
|
||||||
services-folder = mkDir "${config.xlib.dirs.server-home}/Services" "All services folder.";
|
# Xray REALITY inbound on container:443 need this (so nginx
|
||||||
services-mnt-folder = mkDir "/mnt/services" "All services folder.";
|
# stream can forward TLS to Xray via 127.0.0.1:15380 while Xray
|
||||||
services-nodes-folder = mkDir "${config.xlib.dirs.services-mnt-folder}/nodes" "All nodes folder.";
|
# itself sees incoming connections on its configured port 443).
|
||||||
postgresql-folder = mkDir "${config.xlib.dirs.services-mnt-folder}/postgresql" "PostgreSQL service folder.";
|
# Set false on nodes that only run the 3x-ui panel.
|
||||||
};
|
reality443Forwarding = lib.mkOption {
|
||||||
helpers = lib.mkOption {
|
type = lib.types.bool;
|
||||||
type = lib.types.anything;
|
default = false;
|
||||||
default = helpers;
|
description = ''
|
||||||
description = "Shared helper functions (see lib/xlib.nix).";
|
When true, publish host:15380 → container:443 so Xray
|
||||||
};
|
inside the container can serve REALITY on its real
|
||||||
services."3x-ui" = {
|
configured port 443 (nginx stream forwards 443 → 15380).
|
||||||
# Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/)
|
'';
|
||||||
# gets mounted read-only into the 3x-ui container so the panel
|
|
||||||
# can terminate TLS itself. Set null if 3x-ui serves plain HTTP
|
|
||||||
# and TLS is terminated by an upstream nginx.
|
|
||||||
certDomain = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
example = "pubray1.zeroq.su";
|
|
||||||
description = ''
|
|
||||||
Domain whose LE cert should be mounted into the 3x-ui
|
|
||||||
container at /root/cert/fullchain.pem and key.pem.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
# Publish host:15380 → container:443. Only nodes that host an
|
|
||||||
# Xray REALITY inbound on container:443 need this (so nginx
|
|
||||||
# stream can forward TLS to Xray via 127.0.0.1:15380 while Xray
|
|
||||||
# itself sees incoming connections on its configured port 443).
|
|
||||||
# Set false on nodes that only run the 3x-ui panel.
|
|
||||||
reality443Forwarding = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
When true, publish host:15380 → container:443 so Xray
|
|
||||||
inside the container can serve REALITY on its real
|
|
||||||
configured port 443 (nginx stream forwards 443 → 15380).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
xlib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Authelia — SSO reverse-proxy (single-factor password login) for protected
|
||||||
|
# vhosts. Uses nixpkgs' services.authelia module (a native systemd unit with
|
||||||
|
# hard sandboxing) instead of a podman container — Authelia is a Go binary,
|
||||||
|
# not a foreign distro, so a container adds nothing but surface area.
|
||||||
|
#
|
||||||
|
# Wiring:
|
||||||
|
# - The internal API listens on 127.0.0.1:9091 only (overrides the nixpkgs
|
||||||
|
# default `tcp://:9091/` which would bind all interfaces).
|
||||||
|
# - Two secrets (jwt, storage encryption key) come from
|
||||||
|
# modules/server/secrets/authelia.yaml via sops-nix, materialised at
|
||||||
|
# /run/secrets/<name> by the time authelia.service starts.
|
||||||
|
# - nginx is the only ingress: authelia.zeroq.su vhosts the login UI and
|
||||||
|
# every protected vhost (currently vtimeline.zeroq.su) does
|
||||||
|
# `auth_request /authelia` against 127.0.0.1:9091 (see nginx.nix).
|
||||||
|
# - users_database lives at ${xlib.dirs.authelia-folder}/users_database.yml
|
||||||
|
# under /mnt/services/authelia/ — outside of sops, intentionally, so it
|
||||||
|
# can be edited at runtime without `nixos-rebuild` (authelia lazy-reads
|
||||||
|
# the file on each authentication attempt). The directory + file are
|
||||||
|
# pre-created by systemd.tmpfiles below with `authelia:authelia` 0750/0400.
|
||||||
|
#
|
||||||
|
# Version: pinned transitively via flake inputs.nixpkgs → pkgs.authelia.
|
||||||
|
# `nix flake update` will roll it forward; no overlay needed. Package is
|
||||||
|
# also exposed via `environment.systemPackages` so the `authelia` CLI is on
|
||||||
|
# PATH for the `authelia-hash` shell alias (Argon2id password hashing).
|
||||||
|
#
|
||||||
|
# Secrets layout in modules/server/secrets/authelia.yaml (sops-encrypted):
|
||||||
|
# jwt_secret -> /run/secrets/authelia-jwt-secret
|
||||||
|
# storage_encryption_key -> /run/secrets/authelia-storage-encryption-key
|
||||||
|
#
|
||||||
|
# The users database is NOT in this file — it lives at
|
||||||
|
# ${xlib.dirs.authelia-folder}/users_database.yml (see the wiring block
|
||||||
|
# above), edited at runtime without `nixos-rebuild`.
|
||||||
|
#
|
||||||
|
# Guarded by `builtins.pathExists` so a missing sops file does NOT break
|
||||||
|
# `nixos-rebuild switch` — the flake evaluates, Authelia stays disabled
|
||||||
|
# until the secret file is created and encrypted.
|
||||||
|
let
|
||||||
|
cfg = config.host.authelia;
|
||||||
|
sopsReady = builtins.pathExists ./secrets/authelia.yaml;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.host.authelia = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
|
Authelia SSO reverse-proxy. When enabled, exposes the internal API on
|
||||||
|
127.0.0.1:9091 (loopback only — nginx is the only ingress). Activating
|
||||||
|
this option requires modules/server/secrets/authelia.yaml to exist
|
||||||
|
and decrypt successfully; otherwise the toplevel build fails on a
|
||||||
|
missing sopsFile.
|
||||||
|
'';
|
||||||
|
cookieDomain = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "zeroq.su";
|
||||||
|
description = ''
|
||||||
|
Domain scope for Authelia session cookies and the login-UI vhost
|
||||||
|
(`authelia.<cookieDomain>`). All protected vhosts must be subdomains
|
||||||
|
of this value for the session cookie to flow through nginx's
|
||||||
|
auth_request handshake.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
autheliaFqdn = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "authelia.${cfg.cookieDomain}";
|
||||||
|
description = ''
|
||||||
|
Public FQDN where the Authelia login UI is served by nginx. Set this
|
||||||
|
to override the default (authelia.<cookieDomain>) when a CNAME or a
|
||||||
|
different deployment shape requires it.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# Authelia CLI binary on PATH so the `authelia-hash` shell alias works
|
||||||
|
# without `nix-shell`. Same `pkgs.authelia` as the daemon's `package`
|
||||||
|
# below — nothing is rebuilt, just exposed.
|
||||||
|
environment.systemPackages = [ pkgs.authelia ];
|
||||||
|
|
||||||
|
# Pre-create the runtime users_database location at /mnt/services/authelia/.
|
||||||
|
# Authelia is enabled only on `server`-typed hosts (see server/default.nix)
|
||||||
|
# where /mnt/services is the durable mountpoint from `mkServiceStorage`.
|
||||||
|
# The "d" rule creates the directory; the "f" rule seeds an empty file
|
||||||
|
# if absent so authelia.service starts cleanly (and rejects every login
|
||||||
|
# until the file is populated — desired safe default).
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${xlib.dirs.authelia-folder} 0750 authelia authelia - -"
|
||||||
|
"f ${xlib.dirs.authelia-folder}/users_database.yml 0400 authelia authelia - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
services.authelia.instances."" = {
|
||||||
|
enable = true;
|
||||||
|
# Default is already pkgs.authelia; pinned here for clarity and to
|
||||||
|
# give an obvious handle for future overrides (e.g. an overlay to
|
||||||
|
# hold a specific upstream version during CVE windows).
|
||||||
|
package = pkgs.authelia;
|
||||||
|
|
||||||
|
secrets = lib.mkIf sopsReady {
|
||||||
|
# Read paths through `config.sops.secrets.<attr>.path` (not via a
|
||||||
|
# hardcoded "/run/secrets/<attr>") so that any future `path =`
|
||||||
|
# override on the sops block below is picked up automatically —
|
||||||
|
# see invariant S1 in docs/arch/invariants.md.
|
||||||
|
jwtSecretFile = config.sops.secrets."authelia-jwt-secret".path;
|
||||||
|
storageEncryptionKeyFile = config.sops.secrets."authelia-storage-encryption-key".path;
|
||||||
|
};
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
# Override the nixpkgs default (`tcp://:9091/`) — binding all
|
||||||
|
# interfaces would expose the API to the LAN. nginx is the only
|
||||||
|
# allowed ingress, talking to 127.0.0.1:9091.
|
||||||
|
server.address = "tcp://127.0.0.1:9091";
|
||||||
|
log = {
|
||||||
|
level = "info";
|
||||||
|
format = "text";
|
||||||
|
};
|
||||||
|
authentication_backend.file = {
|
||||||
|
# Live users database under /mnt/services/authelia/users_database.yml.
|
||||||
|
# Authelia does NOT validate-config this path — it only opens it
|
||||||
|
# when verifying a user password (lazy read on every login attempt),
|
||||||
|
# so the file can be edited at runtime without restarting the
|
||||||
|
# service. Permissions/owner are set by the systemd.tmpfiles rule
|
||||||
|
# above; sops materialisation was removed intentionally so editing
|
||||||
|
# works without `nixos-rebuild`.
|
||||||
|
path = "${xlib.dirs.authelia-folder}/users_database.yml";
|
||||||
|
password = {
|
||||||
|
algorithm = "argon2id";
|
||||||
|
iterations = 3;
|
||||||
|
salt_length = 16;
|
||||||
|
parallelism = 4;
|
||||||
|
memory = 65536;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
storage.local.path = "/var/lib/authelia/db.sqlite3";
|
||||||
|
session = {
|
||||||
|
expiration = "1h";
|
||||||
|
inactivity = "5m";
|
||||||
|
cookies = [
|
||||||
|
{
|
||||||
|
domain = cfg.cookieDomain;
|
||||||
|
authelia_url = "https://${cfg.autheliaFqdn}/";
|
||||||
|
# NOTE: Authelia 4.x has no per-cookie `secure` knob;
|
||||||
|
# `Set-Cookie`'s Secure flag is auto-determined from the
|
||||||
|
# inbound request's effective scheme at runtime (it does
|
||||||
|
# trust X-Forwarded-Proto when it sees it). The HTTP
|
||||||
|
# loopback binding (server.address = 127.0.0.1:9091)
|
||||||
|
# means this only works because nginx sets
|
||||||
|
# X-Forwarded-Proto $scheme, both via
|
||||||
|
# recommendedProxySettings and explicitly on the
|
||||||
|
# /authelia subrequest in nginx.nix. Don't be tempted
|
||||||
|
# to re-add `secure: "always"` here — validate-config
|
||||||
|
# rejects it as an unknown key.
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
access_control = {
|
||||||
|
default_policy = "deny";
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
# Wildcard against every *.zeroq.su vhost that adds an
|
||||||
|
# `auth_request /authelia` to its nginx config (currently
|
||||||
|
# vtimeline). A bare `domain: "*"` is schema-invalid in
|
||||||
|
# Authelia and silently falls through to default_policy,
|
||||||
|
# which is why the first attempt landed on 403 with no
|
||||||
|
# redirect. Adding a new protected vhost under this domain
|
||||||
|
# requires no change here — the wildcard does the work.
|
||||||
|
domain = "*.${cfg.cookieDomain}";
|
||||||
|
policy = "one_factor";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
notifier = {
|
||||||
|
disable_startup_check = true;
|
||||||
|
filesystem.filename = "/var/lib/authelia/notifier.txt";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# No `settingsFiles` — the users_database file is read directly
|
||||||
|
# via `settings.authentication_backend.file.path` above. Adding
|
||||||
|
# it here would put `users:` under viper's config schema check
|
||||||
|
# (validate-config), which rejects it as an unknown top-level key.
|
||||||
|
};
|
||||||
|
|
||||||
|
# Wait for sops-nix to materialise the secrets before Authelia starts.
|
||||||
|
# Without this, authelia can race ahead of sops and read an empty
|
||||||
|
# /run/secrets on the very first boot after a switch. Existing boots
|
||||||
|
# (when /run/secrets is already populated) skip the wait instantly.
|
||||||
|
# `sops-nix.service` is the systemd service that the sops-nix module
|
||||||
|
# creates to deploy credentials; depending on its name avoids the
|
||||||
|
# "race between tmpfiles-setup and the sops materialiser" that the
|
||||||
|
# previous tmpfiles-symlink design implicitly relied on.
|
||||||
|
systemd.services.authelia.after = [ "sops-nix.service" ];
|
||||||
|
systemd.services.authelia.wants = [ "sops-nix.service" ];
|
||||||
|
|
||||||
|
# sops wiring. Guarded by builtins.pathExists so the flake still
|
||||||
|
# evaluates when ./secrets/authelia.yaml hasn't been created yet —
|
||||||
|
# a clean checkout would otherwise fail every nixos-rebuild switch.
|
||||||
|
# Once the file exists and is encrypted, this condition becomes true
|
||||||
|
# and the two secrets (jwt + storage encryption key) are wired in.
|
||||||
|
# `users_database` is not sops-managed — see the comment on
|
||||||
|
# `settings.authentication_backend.file.path` above for its runtime
|
||||||
|
# location under ${xlib.dirs.authelia-folder}/.
|
||||||
|
sops.secrets = lib.optionalAttrs sopsReady {
|
||||||
|
"authelia-jwt-secret" = {
|
||||||
|
format = "yaml";
|
||||||
|
key = "jwt_secret";
|
||||||
|
sopsFile = ./secrets/authelia.yaml;
|
||||||
|
owner = "authelia";
|
||||||
|
group = "authelia";
|
||||||
|
mode = "0400";
|
||||||
|
};
|
||||||
|
"authelia-storage-encryption-key" = {
|
||||||
|
format = "yaml";
|
||||||
|
key = "storage_encryption_key";
|
||||||
|
sopsFile = ./secrets/authelia.yaml;
|
||||||
|
owner = "authelia";
|
||||||
|
group = "authelia";
|
||||||
|
mode = "0400";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# sapphira (and any other server-class coordinator) — register remote
|
||||||
|
# builders, and make sure the nix daemon (running as root) can resolve the
|
||||||
|
# SSH host alias with its ProxyCommand chain.
|
||||||
|
#
|
||||||
|
# The `host.builder.clients` option itself is declared in
|
||||||
|
# modules/options.nix (cross-module). The actual builder list is set by the
|
||||||
|
# configuration (e.g. configurations/server.nix) — this module is generic
|
||||||
|
# over every entry on the list.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
# Attributes that belong to the SSH matchBlock only — NOT to
|
||||||
|
# `nix.buildMachines` (that schema has no hostKeyAlias/proxyCommand).
|
||||||
|
# Strip them before handing the list to nix.buildMachines.
|
||||||
|
sshOnlyAttrs = [
|
||||||
|
"hostKeyAlias"
|
||||||
|
"proxyCommand"
|
||||||
|
];
|
||||||
|
forNix = b: removeAttrs b sshOnlyAttrs;
|
||||||
|
# After NixOS's nix.buildMachines submodule runs, each entry has all
|
||||||
|
# attributes defaulted (protocol=ssh, systems=[], etc.). Read from that
|
||||||
|
# processed list so the formatter never trips on a missing field.
|
||||||
|
processedBuilders = config.nix.buildMachines;
|
||||||
|
|
||||||
|
# Serialise one builder to the textual format Nix's daemon expects in
|
||||||
|
# `nix.conf`'s `builders` line. Mirrors `buildMachinesText` from
|
||||||
|
# nixos/modules/config/nix-remote-build.nix so the result is identical
|
||||||
|
# to what NixOS writes to /etc/nix/machines — we just inline it instead
|
||||||
|
# of relying on `@/etc/nix/machines`, which Nix 2.34 parses but does
|
||||||
|
# not act on (the daemon's `external-builders` list stays empty and the
|
||||||
|
# client reports "configure remote builders via 'builders'" forever).
|
||||||
|
formatBuilder =
|
||||||
|
b:
|
||||||
|
let
|
||||||
|
# Nix 2.34 refuses to dispatch derivations to a builder whose protocol
|
||||||
|
# is `ssh` (the NixOS default): the daemon leaves `external-builders`
|
||||||
|
# empty even when the `builders` line is well-formed, and the client
|
||||||
|
# falls back to local. `ssh-ng` (the new in-band protocol) actually
|
||||||
|
# opens the dispatcher. Override the NixOS default here.
|
||||||
|
proto = "ssh-ng://";
|
||||||
|
user = if b.sshUser != null && b.sshUser != "" then "${b.sshUser}@" else "";
|
||||||
|
systems =
|
||||||
|
if b.system != null then
|
||||||
|
b.system
|
||||||
|
else if b.systems != [ ] then
|
||||||
|
lib.concatStringsSep "," b.systems
|
||||||
|
else
|
||||||
|
"-";
|
||||||
|
sshKey = if b.sshKey != null && b.sshKey != "" then b.sshKey else "-";
|
||||||
|
maxJobs = toString b.maxJobs;
|
||||||
|
speedFactor = toString b.speedFactor;
|
||||||
|
allFeats = b.supportedFeatures ++ b.mandatoryFeatures;
|
||||||
|
supported = if allFeats == [ ] then "-" else lib.concatStringsSep "," allFeats;
|
||||||
|
mandatory =
|
||||||
|
if b.mandatoryFeatures == [ ] then "-" else lib.concatStringsSep "," b.mandatoryFeatures;
|
||||||
|
publicKey = if b.publicHostKey != null then b.publicHostKey else "-";
|
||||||
|
in
|
||||||
|
lib.concatStringsSep " " [
|
||||||
|
"${proto}${user}${b.hostName}"
|
||||||
|
systems
|
||||||
|
sshKey
|
||||||
|
maxJobs
|
||||||
|
speedFactor
|
||||||
|
supported
|
||||||
|
mandatory
|
||||||
|
publicKey
|
||||||
|
];
|
||||||
|
inlineBuilders = lib.concatMapStringsSep "\n" formatBuilder processedBuilders;
|
||||||
|
|
||||||
|
# One OpenSSH host block per builder that needs a ProxyCommand.
|
||||||
|
# Placed in `programs.ssh.extraConfig` so it ends up in
|
||||||
|
# /etc/ssh/ssh_config (the file OpenSSH consults system-wide, including
|
||||||
|
# for the nix-daemon running as root).
|
||||||
|
#
|
||||||
|
# Only builders with a `proxyCommand` attribute get a block: a builder
|
||||||
|
# reachable on its own (e.g. otreca on a public IP) needs no help from
|
||||||
|
# here. The attribute is the literal ProxyCommand string (passed
|
||||||
|
# verbatim to ssh); the configuration is responsible for matching it
|
||||||
|
# with the `hostName` field.
|
||||||
|
hostBlock = b: ''
|
||||||
|
Host ${b.hostName}
|
||||||
|
User ${b.sshUser}
|
||||||
|
HostKeyAlias ${b.hostKeyAlias or b.hostName}
|
||||||
|
ProxyCommand ${b.proxyCommand}
|
||||||
|
StrictHostKeyChecking accept-new
|
||||||
|
ServerAliveInterval 30
|
||||||
|
ServerAliveCountMax 3
|
||||||
|
ControlMaster auto
|
||||||
|
ControlPersist 60
|
||||||
|
ConnectTimeout 15
|
||||||
|
'';
|
||||||
|
blocks = map hostBlock (lib.filter (b: b ? proxyCommand) config.host.builder.clients);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkIf (config.host.builder.clients != [ ]) {
|
||||||
|
# Off-by-default in NixOS. Without this, the nix-remote-build module
|
||||||
|
# sets `nix.settings.builders = null` and the list is dropped from
|
||||||
|
# /etc/nix/nix.conf entirely, even though `nix.buildMachines` is
|
||||||
|
# populated. (The build-machine list still lands in /etc/nix/machines
|
||||||
|
# but nix-daemon reads `builders`, not /etc/nix/machines, when
|
||||||
|
# distributedBuilds is false.)
|
||||||
|
nix.distributedBuilds = true;
|
||||||
|
nix.buildMachines = map forNix config.host.builder.clients;
|
||||||
|
# Nix 2.34's daemon does not act on `@/etc/nix/machines` (the file
|
||||||
|
# format NixOS's nix-remote-build writes to): the `builders` config
|
||||||
|
# key is parsed for display but `external-builders` stays empty and
|
||||||
|
# the scheduler ignores it. Inlining the same builder text here — in
|
||||||
|
# the exact format the NixOS module itself uses — actually wires up
|
||||||
|
# the SSH dispatch. `mkForce` is required because the nix-remote-build
|
||||||
|
# module sets `builders = null` whenever distributedBuilds is *false*;
|
||||||
|
# our config flips it to *true*, so the module's mkIf does not fire
|
||||||
|
# and there is no actual conflict — but pinning it with mkForce makes
|
||||||
|
# the intent obvious and survives any future change in default
|
||||||
|
# behaviour.
|
||||||
|
nix.settings.builders = lib.mkForce inlineBuilders;
|
||||||
|
|
||||||
|
# Append per-builder Host blocks to the system-wide OpenSSH client
|
||||||
|
# config. `programs.ssh.extraConfig` is of type `lines`, merged across
|
||||||
|
# modules, and prepended (before `Host *`) in /etc/ssh/ssh_config —
|
||||||
|
# which is exactly the spot where specific Host blocks have to live.
|
||||||
|
programs.ssh.extraConfig = lib.concatStrings blocks;
|
||||||
|
|
||||||
|
# Parallel builds on sapphira itself stay at 2 — that matches the
|
||||||
|
# physical cores and keeps the coordinator responsive while the WSL
|
||||||
|
# absorbs the heavy lifting. The essentials/settings.nix already
|
||||||
|
# leaves max-jobs at the default `auto` (2 here); no override needed.
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
zeroq.su:53 {
|
zeroq.su:53 {
|
||||||
hosts {
|
hosts {
|
||||||
109.248.161.5 x.zeroq.su
|
109.248.161.5 x.zeroq.su
|
||||||
|
192.168.1.20 authelia.zeroq.su
|
||||||
192.168.1.20 calibre.zeroq.su
|
192.168.1.20 calibre.zeroq.su
|
||||||
192.168.1.20 dns.zeroq.su
|
192.168.1.20 dns.zeroq.su
|
||||||
192.168.1.20 flux.zeroq.su
|
192.168.1.20 flux.zeroq.su
|
||||||
@@ -20,11 +21,13 @@
|
|||||||
192.168.1.20 kuma.zeroq.su
|
192.168.1.20 kuma.zeroq.su
|
||||||
192.168.1.20 navidrome.zeroq.su
|
192.168.1.20 navidrome.zeroq.su
|
||||||
192.168.1.20 nextcloud.zeroq.su
|
192.168.1.20 nextcloud.zeroq.su
|
||||||
|
192.168.1.20 open.zeroq.su
|
||||||
192.168.1.20 office.zeroq.su
|
192.168.1.20 office.zeroq.su
|
||||||
192.168.1.20 pdf.zeroq.su
|
192.168.1.20 pdf.zeroq.su
|
||||||
192.168.1.20 syncthing.zeroq.su
|
192.168.1.20 syncthing.zeroq.su
|
||||||
192.168.1.20 talk.zeroq.su
|
192.168.1.20 talk.zeroq.su
|
||||||
192.168.1.20 turn.zeroq.su
|
192.168.1.20 turn.zeroq.su
|
||||||
|
192.168.1.20 vtimeline.zeroq.su
|
||||||
fallthrough
|
fallthrough
|
||||||
}
|
}
|
||||||
cache 300
|
cache 300
|
||||||
|
|||||||
@@ -6,9 +6,13 @@
|
|||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
../containers/3x-ui.nix
|
../containers/3x-ui.nix
|
||||||
|
../containers/open-webui.nix
|
||||||
|
../containers/tape-rotation.nix
|
||||||
../pkgs/beets.nix
|
../pkgs/beets.nix
|
||||||
./acme.nix
|
./acme.nix
|
||||||
|
./authelia.nix
|
||||||
./bentopdf.nix
|
./bentopdf.nix
|
||||||
|
./builder.nix
|
||||||
./calibre-web.nix
|
./calibre-web.nix
|
||||||
./chrony.nix
|
./chrony.nix
|
||||||
./coredns.nix
|
./coredns.nix
|
||||||
@@ -16,7 +20,6 @@
|
|||||||
./glances.nix
|
./glances.nix
|
||||||
./homebox.nix
|
./homebox.nix
|
||||||
./immich.nix
|
./immich.nix
|
||||||
# ./minecraft.nix
|
|
||||||
./miniflux.nix
|
./miniflux.nix
|
||||||
./navidrome.nix
|
./navidrome.nix
|
||||||
./nextcloud.nix
|
./nextcloud.nix
|
||||||
@@ -28,15 +31,17 @@
|
|||||||
./samba.nix
|
./samba.nix
|
||||||
./syncthing.nix
|
./syncthing.nix
|
||||||
./systemd.nix
|
./systemd.nix
|
||||||
|
./ttyd.nix
|
||||||
|
./vtimeline.nix
|
||||||
./uptime-kuma.nix
|
./uptime-kuma.nix
|
||||||
# ../containers/remnawave.nix
|
# ../containers/remnawave.nix
|
||||||
# ./coturn.nix
|
# ./coturn.nix
|
||||||
# ./mealie.nix
|
# ./mealie.nix
|
||||||
# ./memos.nix
|
# ./memos.nix
|
||||||
|
# ./minecraft.nix
|
||||||
# ./n8n.nix
|
# ./n8n.nix
|
||||||
# ./netdata.nix
|
# ./netdata.nix
|
||||||
# ./nfs.nix
|
# ./nfs.nix
|
||||||
# ./open-webui.nix
|
|
||||||
# ./rsync.nix
|
# ./rsync.nix
|
||||||
# ./step-ca.nix
|
# ./step-ca.nix
|
||||||
# ./stirling-pdf.nix
|
# ./stirling-pdf.nix
|
||||||
@@ -49,7 +54,14 @@
|
|||||||
# there are other vhosts on the same port). Cert is still mounted in
|
# there are other vhosts on the same port). Cert is still mounted in
|
||||||
# case 3x-ui is later reconfigured to terminate TLS itself (e.g. for
|
# case 3x-ui is later reconfigured to terminate TLS itself (e.g. for
|
||||||
# direct node-API access); nginx doesn't have to use it.
|
# direct node-API access); nginx doesn't have to use it.
|
||||||
xlib.services."3x-ui".certDomain = "x.zeroq.su";
|
host."3x-ui".certDomain = "x.zeroq.su";
|
||||||
|
# Authelia SSO — currently protects vtimeline.zeroq.su (replaces the
|
||||||
|
# previous nginx auth_basic htpasswd). Cookie domain is .zeroq.su so a
|
||||||
|
# single Authelia session covers every protected vhost under the zone.
|
||||||
|
host.authelia = {
|
||||||
|
enable = true;
|
||||||
|
cookieDomain = "zeroq.su";
|
||||||
|
};
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
(xlib.helpers.mkTmpfile "d" "/mnt" "0755" "root" "root")
|
(xlib.helpers.mkTmpfile "d" "/mnt" "0755" "root" "root")
|
||||||
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
|
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
};
|
};
|
||||||
nextcloud = {
|
nextcloud = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = pkgs.nextcloud34;
|
package = pkgs.nextcloud35;
|
||||||
hostName = "nextcloud.private";
|
hostName = "nextcloud.private";
|
||||||
database.createLocally = true;
|
database.createLocally = true;
|
||||||
home = "${xlib.dirs.services-mnt-folder}/nextcloud";
|
home = "${xlib.dirs.services-mnt-folder}/nextcloud";
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
dbtype = "pgsql";
|
dbtype = "pgsql";
|
||||||
dbuser = "nextcloud";
|
dbuser = "nextcloud";
|
||||||
dbname = "nextcloud";
|
dbname = "nextcloud";
|
||||||
adminuser = "oqyude";
|
adminuser = xlib.device.username;
|
||||||
adminpassFile = config.sops.secrets.nextcloud-adminpass.path;
|
adminpassFile = config.sops.secrets.nextcloud-adminpass.path;
|
||||||
};
|
};
|
||||||
settings = {
|
settings = {
|
||||||
@@ -123,9 +123,9 @@
|
|||||||
mail
|
mail
|
||||||
music
|
music
|
||||||
notes
|
notes
|
||||||
onlyoffice
|
# onlyoffice
|
||||||
polls
|
polls
|
||||||
previewgenerator
|
# previewgenerator
|
||||||
spreed
|
spreed
|
||||||
tables
|
tables
|
||||||
tasks
|
tasks
|
||||||
|
|||||||
+169
-4
@@ -61,6 +61,16 @@ let
|
|||||||
domain = "flux.zeroq.su";
|
domain = "flux.zeroq.su";
|
||||||
port = 6061;
|
port = 6061;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
domain = "tape-rotation.zeroq.su";
|
||||||
|
port = 5174;
|
||||||
|
}
|
||||||
|
# NOTE: open.zeroq.su is intentionally NOT in this `sites` list —
|
||||||
|
# mkProxy hard-codes ${server} = 192.168.1.20, but the Open WebUI
|
||||||
|
# container binds to 127.0.0.1:8080 only (loopback, see
|
||||||
|
# modules/containers/open-webui.nix). The vhost is added directly
|
||||||
|
# to `virtualHosts` below, alongside x.zeroq.su (3x-ui panel,
|
||||||
|
# same loopback-only pattern).
|
||||||
{
|
{
|
||||||
domain = "navidrome.zeroq.su";
|
domain = "navidrome.zeroq.su";
|
||||||
port = 4533;
|
port = 4533;
|
||||||
@@ -113,6 +123,124 @@ in
|
|||||||
forceSSL = true;
|
forceSSL = true;
|
||||||
enableACME = true;
|
enableACME = true;
|
||||||
};
|
};
|
||||||
|
# vtimeline.zeroq.su — Veeam Timeline View. Reverse-proxies the
|
||||||
|
# entire vhost to a local Node.js/Express process (managed by
|
||||||
|
# systemd as `vtimeline-api` — see modules/server/vtimeline.nix),
|
||||||
|
# which serves both the static frontend (public_html/) and the
|
||||||
|
# /api/uploads JSON-upload CRUD over a single listener on
|
||||||
|
# 127.0.0.1:8000. Authelia forward-auth is wired on `/` so every
|
||||||
|
# hit (static OR /api/*) requires a valid session cookie.
|
||||||
|
#
|
||||||
|
# client_max_body_size 6m matches the server.js body limit
|
||||||
|
# (5 MB hard cap). nginx's default 1m would 413 any upload near
|
||||||
|
# the cap before the request reached the node process.
|
||||||
|
"vtimeline.zeroq.su" = {
|
||||||
|
forceSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
locations = {
|
||||||
|
"/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:8000";
|
||||||
|
proxyWebsockets = true;
|
||||||
|
extraConfig = ''
|
||||||
|
auth_request /authelia;
|
||||||
|
auth_request_set $authelia_user $upstream_http_remote_user;
|
||||||
|
# Authelia for an anonymous user on a `one_factor`-protected
|
||||||
|
# vhost returns 302 + Location to the login UI by default
|
||||||
|
# (because nginx forwards Accept: text/html). nginx's
|
||||||
|
# auth_request only treats 2xx/4xx as pass/deny, so a bare
|
||||||
|
# 302 surfaces to the client as 500 ("auth request
|
||||||
|
# unexpected status"). Converting 401 → 302 to the
|
||||||
|
# login page handles that case. Authelia returns 401 only
|
||||||
|
# when the subrequest advertises Accept: application/json
|
||||||
|
# (see the /authelia block below).
|
||||||
|
# `$request_uri` is the URI path only — Authelia would
|
||||||
|
# then resolve `rd` as relative to its own `authelia_url`
|
||||||
|
# and send the user back to `authelia.zeroq.su/<path>`,
|
||||||
|
# not `vtimeline.zeroq.su/<path>`, after a successful
|
||||||
|
# login. Pass the full origin (scheme + host + path) so
|
||||||
|
# Authelia constructs an absolute redirect back to the
|
||||||
|
# original vhost.
|
||||||
|
error_page 401 =302 https://authelia.zeroq.su/?rd=$scheme://$host$request_uri;
|
||||||
|
client_max_body_size 6m;
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
"= /authelia" = {
|
||||||
|
extraConfig = ''
|
||||||
|
internal;
|
||||||
|
proxy_pass http://127.0.0.1:9091/api/authz/forward-auth;
|
||||||
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Method $request_method;
|
||||||
|
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
# Force Authelia to respond with 401 (not 302 + Location) so
|
||||||
|
# the error_page 401 =302 rule above can take over. With the
|
||||||
|
# default Accept: text/html Authelia sends a 302 with an
|
||||||
|
# absolute Location, which auth_request surfaces to the client
|
||||||
|
# as 500 ("unexpected status").
|
||||||
|
proxy_set_header Accept "application/json";
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# tty.zeroq.su — web-shell (ttyd) behind Authelia forward-auth.
|
||||||
|
# ttyd listens on 127.0.0.1:7681 only (modules/server/ttyd.nix), so
|
||||||
|
# nginx is the only ingress. Same auth_request / 401→302 wiring as
|
||||||
|
# vtimeline.zeroq.su above — the wildcard rule `*.zeroq.su` in
|
||||||
|
# modules/server/authelia.nix already covers this subdomain under
|
||||||
|
# `one_factor`, so no policy change is needed.
|
||||||
|
"tty.zeroq.su" = {
|
||||||
|
forceSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
locations = {
|
||||||
|
"/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:7681";
|
||||||
|
proxyWebsockets = true;
|
||||||
|
extraConfig = ''
|
||||||
|
auth_request /authelia;
|
||||||
|
auth_request_set $authelia_user $upstream_http_remote_user;
|
||||||
|
# Same 401→302 trick as vtimeline.zeroq.su: a bare 302 from
|
||||||
|
# Authelia surfaces to the client as a 500 ("auth request
|
||||||
|
# unexpected status"), so we rewrite the response status to
|
||||||
|
# a 302 pointing at the authelia login UI with an absolute
|
||||||
|
# `$scheme://$host$request_uri` so the post-login `rd`
|
||||||
|
# lands the user back on tty.zeroq.su, not on
|
||||||
|
# authelia.zeroq.su/<path>.
|
||||||
|
error_page 401 =302 https://authelia.zeroq.su/?rd=$scheme://$host$request_uri;
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
"= /authelia" = {
|
||||||
|
extraConfig = ''
|
||||||
|
internal;
|
||||||
|
proxy_pass http://127.0.0.1:9091/api/authz/forward-auth;
|
||||||
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Method $request_method;
|
||||||
|
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
# Same Accept-forces-401 trick as vtimeline.zeroq.su — see
|
||||||
|
# the comment there for why Authelia's default 302 is
|
||||||
|
# harmful here.
|
||||||
|
proxy_set_header Accept "application/json";
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# Authelia login UI — same podman container on 127.0.0.1:9091 as the
|
||||||
|
# forward-auth endpoint above, just exposed on a separate vhost so
|
||||||
|
# Authelia has a stable absolute URL to redirect users to. Authelia
|
||||||
|
# generates internal links against $session.cookies[0].authelia_url,
|
||||||
|
# which is set to https://${autheliaFqdn}/ in modules/server/authelia.nix.
|
||||||
|
"authelia.zeroq.su" = {
|
||||||
|
forceSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
locations."/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:9091";
|
||||||
|
proxyWebsockets = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
"pdf.private" = {
|
"pdf.private" = {
|
||||||
forceSSL = false;
|
forceSSL = false;
|
||||||
enableACME = false;
|
enableACME = false;
|
||||||
@@ -141,23 +269,42 @@ in
|
|||||||
enableACME = true;
|
enableACME = true;
|
||||||
locations = {
|
locations = {
|
||||||
"/" = {
|
"/" = {
|
||||||
proxyPass = "http://${server}:2049";
|
proxyPass = "http://127.0.0.1:2049";
|
||||||
proxyWebsockets = true;
|
proxyWebsockets = true;
|
||||||
};
|
};
|
||||||
"/subs/" = {
|
"/subs/" = {
|
||||||
proxyPass = "http://${server}:2096";
|
proxyPass = "http://127.0.0.1:2096";
|
||||||
proxyWebsockets = true;
|
proxyWebsockets = true;
|
||||||
};
|
};
|
||||||
"/subsjs/" = {
|
"/subsjs/" = {
|
||||||
proxyPass = "http://${server}:2096";
|
proxyPass = "http://127.0.0.1:2096";
|
||||||
proxyWebsockets = true;
|
proxyWebsockets = true;
|
||||||
};
|
};
|
||||||
"/clash/" = {
|
"/clash/" = {
|
||||||
proxyPass = "http://${server}:2096";
|
proxyPass = "http://127.0.0.1:2096";
|
||||||
proxyWebsockets = true;
|
proxyWebsockets = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
# Open WebUI — same loopback-only pattern as x.zeroq.su above.
|
||||||
|
# The container listens on 127.0.0.1:8080 (modules/containers/open-webui.nix),
|
||||||
|
# so we proxy_pass to 127.0.0.1, not the LAN IP. The two extra
|
||||||
|
# directives are required by the upstream HTTPS docs:
|
||||||
|
# proxy_buffering off for SSE streaming (markdown in chat breaks
|
||||||
|
# under the default `proxy_buffering on` from recommendedProxySettings),
|
||||||
|
# and a 300 s read timeout for long LLM completions.
|
||||||
|
"open.zeroq.su" = {
|
||||||
|
forceSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
locations."/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:8080";
|
||||||
|
proxyWebsockets = true;
|
||||||
|
};
|
||||||
|
extraConfig = ''
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
'';
|
||||||
|
};
|
||||||
"zeroq.su" = {
|
"zeroq.su" = {
|
||||||
forceSSL = true;
|
forceSSL = true;
|
||||||
enableACME = true;
|
enableACME = true;
|
||||||
@@ -190,6 +337,17 @@ in
|
|||||||
proxyWebsockets = true;
|
proxyWebsockets = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
# sapphira itself: opencode web runs as a systemd user service
|
||||||
|
# (programs.opencode.web.enable in home/modules/opencode.nix) on
|
||||||
|
# 127.0.0.1:4096 with --hostname 0.0.0.0.
|
||||||
|
"opencode.zeroq.su" = {
|
||||||
|
forceSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
locations."/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:4096";
|
||||||
|
proxyWebsockets = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
"nextcloud.zeroq.su" = {
|
"nextcloud.zeroq.su" = {
|
||||||
forceSSL = true;
|
forceSSL = true;
|
||||||
enableACME = true;
|
enableACME = true;
|
||||||
@@ -211,4 +369,11 @@ in
|
|||||||
80
|
80
|
||||||
443
|
443
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Note: the previous vtimeline-htpasswd sops declaration lived here. It
|
||||||
|
# was removed when authelia replaced nginx's auth_basic (see the vtimeline
|
||||||
|
# vhost above). The encrypted file modules/server/secrets/vtimeline-htpasswd.yaml
|
||||||
|
# itself was kept untouched per the repo policy of not modifying secrets
|
||||||
|
# without explicit owner sign-off; delete it with `sops --version` and
|
||||||
|
# `rm` once the cutover is verified.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
xlib,
|
xlib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
# let
|
||||||
# previous = import inputs.nixpkgs-master {
|
# previous = import inputs.nixpkgs-master {
|
||||||
# system = "x86_64-linux";
|
# system = "x86_64-linux";
|
||||||
# config.allowUnfree = true;
|
# config.allowUnfree = true;
|
||||||
# config.allowUnfreePredicate = true;
|
# config.allowUnfreePredicate = true;
|
||||||
# };
|
# };
|
||||||
in
|
# in
|
||||||
{
|
{
|
||||||
services.onlyoffice = {
|
services.onlyoffice = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
config,
|
|
||||||
inputs,
|
|
||||||
lib,
|
|
||||||
pkgs,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
{
|
|
||||||
services = {
|
|
||||||
open-webui = {
|
|
||||||
enable = false;
|
|
||||||
host = "0.0.0.0";
|
|
||||||
port = 11112;
|
|
||||||
openFirewall = true;
|
|
||||||
environment = {
|
|
||||||
ANONYMIZED_TELEMETRY = "False";
|
|
||||||
DO_NOT_TRACK = "True";
|
|
||||||
SCARF_NO_ANALYTICS = "True";
|
|
||||||
OPENAI_API_BASE_URL = "http://192.168.1.100:1234/v1";
|
|
||||||
#OLLAMA_API_BASE_URL = "http://127.0.0.1:1234";
|
|
||||||
WEBUI_AUTH = "True";
|
|
||||||
ENABLE_SIGNUP = "False";
|
|
||||||
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = "True";
|
|
||||||
ENABLE_VERSION_UPDATE_CHECK = "False";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
jwt_secret: ENC[AES256_GCM,data:Sq3SR3GF2PKU/EmtosEIgkVBGx0ycQfYBy3SmNB46bflTX3HvjY7gXSXt13khLRNjYwqnLQ/VWnhSF7QmjO+QA==,iv:L1c/Tb1nb1JNY9FdU7SoZih9CdRO0sDErA+OBmCe1nY=,tag:aYS8NoMW1OqVT/q60nVU6A==,type:str]
|
||||||
|
storage_encryption_key: ENC[AES256_GCM,data:1uoto0pqv1ahIxc00XzY+X0I0Eb3po/FPWOsmyFlcOhtpzK3od0+4a2jL8PicqbIf6F0hNp1gYUc11ofO7Mj1g==,iv:IFw4Y/cL3Hqa0fIPeKhN3SdrlOLFFJiJLe1MTNue+gk=,tag:eSAFNxpkaYWkyFVoWzSuMA==,type:str]
|
||||||
|
sops:
|
||||||
|
age:
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhYjh3WjVTN2JXdjJXYXY3
|
||||||
|
UnhveDN2WkxsRThQWVhpTlBHL0FiSEF4dVZRCnRrQlZiM3hxU0FzUHM3YVpkOXpF
|
||||||
|
alowVnpENWUzdzRYQ1R5cW5CVDVEcTQKLS0tIDU1MFJCQUFIdUoyWHBQSkwweUMv
|
||||||
|
SFhPQmFOdmY5QUpLVUdpUGRWL25aTE0K5BL3mIZI6Wl4TZIKQPUJ7PxvrNe+1t+S
|
||||||
|
ra8EJtInXy4HeNWye9sdR3BHD7QWYT46RAHBSdn+4SxtV6LOHiXBgg==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
|
||||||
|
lastmodified: "2026-10-09T13:32:43Z"
|
||||||
|
mac: ENC[AES256_GCM,data:Sde8LhaRmbGu43Aa+jvV3ZjR6e1wkAXVOtoQ4fcmi4UJEWPGJNUu6fGHRCWEvlNFo1XCN0WEQuC9yycMaz8nIGizCAT8P31qgamV+f++Anxct1TwcF65H4ZN7cfAyqJaODGBsue7GjvrSJMLstOEB37sQKrK/e3ktG8lTtUzKsA=,iv:5eQmj/xy51TKwPF/0joezYga0LYjeik7FK1PamPbP6M=,tag:bHw04M8wpRDTX6uM8vz2kQ==,type:str]
|
||||||
|
unencrypted_suffix: _unencrypted
|
||||||
|
version: 3.13.3
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# ttyd — web-терминал на 127.0.0.1:7681 (loopback only), за
|
||||||
|
# reverse-proxy vhost `tty.zeroq.su` (modules/server/nginx.nix).
|
||||||
|
# Nginx + authelia — единственный ingress; ttyd снаружи недоступен.
|
||||||
|
#
|
||||||
|
# Рантайм-наблюдение (Oct 2026): первый прогон с
|
||||||
|
# `entrypoint = [ pkgs.bashInteractive ]` поднимал bash на slave-pty
|
||||||
|
# (/dev/pts/N создавался, fd 0/1/2 указывали туда), но bash НЕ входил
|
||||||
|
# в interactive mode — wchan уходил в `poll_schedule_timeout`, PS1 не
|
||||||
|
# рисовался, xterm.js канвас оставался пустым. Без явного `-i` и без
|
||||||
|
# TERM в env systemd-юнита bash имеет право считать себя non-interactive
|
||||||
|
# даже при isatty(0)=true. Лечится обоими сразу:
|
||||||
|
# 1. entrypoint = login-shell пользователя + явный `-i`;
|
||||||
|
# 2. systemd.services.ttyd.environment с TERM и HOME.
|
||||||
|
#
|
||||||
|
# Дополнительно: переключаемся с bash на zsh потому, что у `oqyude`
|
||||||
|
# в /etc/passwd shell = /run/current-system/sw/bin/zsh. Подсовывать
|
||||||
|
# bash шеллу, чей home настроен на zsh, значит терять dotfiles и
|
||||||
|
# PATH-модификации, загружаемые при штатном login.
|
||||||
|
#
|
||||||
|
# `-l` (login-mode) подгружает полный login-env NixOS: /etc/zshenv,
|
||||||
|
# /etc/zprofile, ~/.zprofile и через /etc/profile — все
|
||||||
|
# /etc/profile.d/*.sh, где NixOS выставляет PATH, XDG_DATA_DIRS,
|
||||||
|
# NIX_PATH и т.п. Без `-l` PATH остаётся тем узким, что в
|
||||||
|
# systemd-Environment (coreutils/findutils/grep/sed/systemd) — без
|
||||||
|
# nix/git/docker/man/…, что и было видно в первом прогоне.
|
||||||
|
#
|
||||||
|
# Замечание про PATH: NixOS раскладывает login-env по двум НЕСВЯЗАННЫМ
|
||||||
|
# стекам — bash-стейку (/etc/profile → set-environment с PATH/XDG/
|
||||||
|
# NIX_PATH/GTK_PATH/INFOPATH/…) и zsh-стейку (/etc/zprofile + /etc/zshrc).
|
||||||
|
# Zsh сам по себе /etc/profile не source'ит — поэтому PATH внутри
|
||||||
|
# ttyd-shell остаётся урезанным (только coreutils/findutils/grep/sed/
|
||||||
|
# systemd от systemd.Environment). Это сознательно НЕ лечится здесь
|
||||||
|
# (см. todo D2 в docs/arch/todo.md если файл существует): вопрос
|
||||||
|
# глобальный, должен решаться или через environment.etc."zshrc.local"
|
||||||
|
# в modules/essentials/, или через ~/.zshenv у пользователя. На этом
|
||||||
|
# этапе ограничиваемся `zsh -i -l` — оно уже подгружает /etc/zprofile
|
||||||
|
# (cd /etc/nixos + fastfetch) и /etc/zshrc (oh-my-zsh, aliases,
|
||||||
|
# compinit, syntax-highlighting). Это то, что просили.
|
||||||
|
#
|
||||||
|
# Решения, отступающие от дефолтов upstream `services.ttyd`:
|
||||||
|
#
|
||||||
|
# user = "oqyude"
|
||||||
|
# Default — root. С authelia `one_factor` (один пароль) это
|
||||||
|
# эквивалент "root-shell за единым паролем". Идём от
|
||||||
|
# пользователя-администратора (modules/users.nix: oqyude,
|
||||||
|
# isNormalUser, wheel/disk/audio/networkmanager/libvirtd). Цена:
|
||||||
|
# ttyd-юзер не правит systemd-юниты напрямую — для этого sudo.
|
||||||
|
#
|
||||||
|
# interface = "127.0.0.1"
|
||||||
|
# Биндим исключительно на loopback. Роутер пробрасывает 443 (плюс
|
||||||
|
# 80/22/8443/22000) на sapphira, но сам ttyd наружу выставлять
|
||||||
|
# нельзя: это "SSH на порту 7681 без TLS". Доступ — ТОЛЬКО через
|
||||||
|
# 127.0.0.1 + nginx-proxy.
|
||||||
|
#
|
||||||
|
# entrypoint = [ userShell "-i" "-l" ]
|
||||||
|
# userShell — login-shell пользователя из users.users.<name>.shell
|
||||||
|
# (fallback = /run/current-system/sw/bin/bash, если атрибут не
|
||||||
|
# выставлен). `-i` форсит interactive mode — без него bash/zsh
|
||||||
|
# могут стартовать non-interactive при наличии pty в fd 0.
|
||||||
|
# `-l` (login-shell) подгружает /etc/zshenv, /etc/zprofile,
|
||||||
|
# /etc/zshrc — login-env zsh-стейка NixOS: cd /etc/nixos +
|
||||||
|
# fastfetch, oh-my-zsh, aliases, compinit, syntax-highlighting.
|
||||||
|
# PATH остаётся урезанным — см. блок про PATH-замечание выше.
|
||||||
|
#
|
||||||
|
# writeable = true
|
||||||
|
# Upstream-assertion требует явного значения.
|
||||||
|
#
|
||||||
|
# checkOrigin = true
|
||||||
|
# Без него WebSocket-апгрейд от любого origin проходит — XSS на
|
||||||
|
# любом *.zeroq.su vhost превращается в RCE через ttyd.
|
||||||
|
#
|
||||||
|
# maxClients = 0 (default)
|
||||||
|
# Решение владельца: без лимита; ttyd разделяет TTY между всеми
|
||||||
|
# WS-сессиями, состояние общее.
|
||||||
|
#
|
||||||
|
# Шрифт (clientOptions.fontFamily): НЕ задаём. Ttyd генерирует HTML
|
||||||
|
# с fallback-стеком `courier-new, courier, monospace`, который на
|
||||||
|
# клиенте отрисовывается через CSS-generic → monospace (Liberation Mono
|
||||||
|
# на Linux, Menlo на macOS, Consolas/Cascadia на Windows). Fira Code
|
||||||
|
# (предыдущее значение) требовал установленный на клиенте шрифт; если
|
||||||
|
# его нет, xterm.js падает на generic mono, но в Canvas API без
|
||||||
|
# CSS-стека рендеринг становится нестабильным. Явный `monospace` =
|
||||||
|
# "всегда рисуется системным шрифтом". Если потом захочется Fira
|
||||||
|
# Code / JetBrains Mono / другой — задать `fontFamily = "Fira Code,
|
||||||
|
# monospace"` (с trailing monospace, чтобы Canvas нашёл fallback).
|
||||||
|
#
|
||||||
|
# systemd-окружение: TERM и HOME пробрасываются явно. systemd по
|
||||||
|
# дефолту не выставляет TERM (User=oqyude даёт только euid), а
|
||||||
|
# bash/zsh полагаются на TERM для history-substitution и line-editing.
|
||||||
|
# HOME нужен zsh для определения `ZDOTDIR` (~/.zshrc и т.п.).
|
||||||
|
let
|
||||||
|
cfg = config.services.ttyd;
|
||||||
|
# `users.users.<name>.shell` в NixOS 26.x — это пакет (derivation),
|
||||||
|
# а в 24.x был путём. `lib.getExe` умеет оба: derivation → bin/<name>,
|
||||||
|
# string → возвращает как есть.
|
||||||
|
userShellExe =
|
||||||
|
let
|
||||||
|
shell = config.users.users.${cfg.user}.shell or "/run/current-system/sw/bin/bash";
|
||||||
|
in
|
||||||
|
if builtins.isString shell then shell else lib.getExe shell;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
services.ttyd = {
|
||||||
|
enable = true;
|
||||||
|
port = 7681;
|
||||||
|
interface = "127.0.0.1";
|
||||||
|
user = "oqyude";
|
||||||
|
entrypoint = [
|
||||||
|
userShellExe
|
||||||
|
"-i"
|
||||||
|
"-l"
|
||||||
|
];
|
||||||
|
writeable = true;
|
||||||
|
checkOrigin = true;
|
||||||
|
maxClients = 0;
|
||||||
|
clientOptions = {
|
||||||
|
fontSize = "14";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.ttyd.environment = {
|
||||||
|
HOME = "/home/oqyude";
|
||||||
|
TERM = "xterm-256color";
|
||||||
|
USER = "oqyude";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
xlib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
# Veeam Timeline View — static frontend + REST API, both served by a
|
||||||
|
# single Node.js/Express process. nginx reverse-proxies the entire
|
||||||
|
# vtimeline.zeroq.su vhost to the local listener (see the vhost in
|
||||||
|
# modules/server/nginx.nix — no `root`, no `try_files`).
|
||||||
|
#
|
||||||
|
# API surface (server.js):
|
||||||
|
# GET /api/uploads list uploaded JSONs
|
||||||
|
# POST /api/uploads upload (raw JSON or {name, content}, ≤ 5 MB)
|
||||||
|
# GET /api/uploads/:name fetch parsed
|
||||||
|
# DELETE /api/uploads/:name delete
|
||||||
|
#
|
||||||
|
# Auth: delegated to Authelia at the nginx layer (`auth_request
|
||||||
|
# /authelia` on the vhost). The API itself trusts whoever reaches it —
|
||||||
|
# the only ingress is the same-origin nginx proxy, so the wide-open
|
||||||
|
# CORS header in server.js is a no-op in practice.
|
||||||
|
#
|
||||||
|
# Storage: /home/oqyude/External/Git/VeeamTimelineView/
|
||||||
|
# ├── public_html/ static frontend (read-only at runtime)
|
||||||
|
# ├── server.js Express app
|
||||||
|
# ├── node_modules/ installed deps (express + transitive)
|
||||||
|
# └── uploads/ persistent JSON uploads (read+write)
|
||||||
|
#
|
||||||
|
# The repo lives on the External ext4 drive (fstab). The service gates
|
||||||
|
# on home-oqyude-External.mount so it never starts against an empty
|
||||||
|
# directory after a cold boot without the drive plugged in.
|
||||||
|
let
|
||||||
|
user = xlib.device.username;
|
||||||
|
group = "users";
|
||||||
|
repo = "/home/oqyude/External/Git/VeeamTimelineView";
|
||||||
|
port = 8000;
|
||||||
|
node = pkgs.nodejs_22;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# systemPackages so /run/current-system/sw/bin/node exists for any
|
||||||
|
# operator tooling (logs, ad-hoc npm scripts). The systemd unit
|
||||||
|
# below references the absolute Nix-store path, so this is purely
|
||||||
|
# for the CLI path.
|
||||||
|
environment.systemPackages = [ node ];
|
||||||
|
|
||||||
|
# Bind-mount public_html into /var/lib. The current vhost proxies
|
||||||
|
# all traffic to the node listener, so nginx itself does not need
|
||||||
|
# this — kept for parity with the pre-API setup (so any future
|
||||||
|
# static-only fallback or external inspection has a stable
|
||||||
|
# /home-independent path). x-systemd.automount + nofail: a missing
|
||||||
|
# /home/oqyude/External only surfaces as a per-request 404, never a
|
||||||
|
# boot failure.
|
||||||
|
systemd.mounts = [
|
||||||
|
(xlib.helpers.mkSystemdBind {
|
||||||
|
what = "${repo}/public_html";
|
||||||
|
where = "/var/lib/vtimeline";
|
||||||
|
})
|
||||||
|
];
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
(xlib.helpers.mkTmpfile "d" "/var/lib/vtimeline" "0755" "nginx" "nginx")
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.vtimeline-api = {
|
||||||
|
description = "Veeam Timeline View API (Node.js + Express)";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
after = [
|
||||||
|
"network-online.target"
|
||||||
|
"home-oqyude-External.mount"
|
||||||
|
];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
requires = [ "home-oqyude-External.mount" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = user;
|
||||||
|
Group = group;
|
||||||
|
WorkingDirectory = repo;
|
||||||
|
ExecStart = "${node}/bin/node ${repo}/server.js";
|
||||||
|
Environment = "PORT=${toString port}";
|
||||||
|
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
|
||||||
|
# Sandbox. Server.js only needs to read public_html/ +
|
||||||
|
# node_modules/ and to read-write uploads/. ReadWritePaths
|
||||||
|
# lifts the ProtectHome/ProtectSystem write protection for
|
||||||
|
# uploads/ only; everywhere else stays read-only.
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = "read-only";
|
||||||
|
ReadWritePaths = [ "${repo}/uploads" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
+61
-6
@@ -8,7 +8,9 @@ let
|
|||||||
user = "${xlib.device.username}";
|
user = "${xlib.device.username}";
|
||||||
userGroup = config.users.users."${user}".group;
|
userGroup = config.users.users."${user}".group;
|
||||||
|
|
||||||
# sops secret factory: name == key by default, owner/group default to root
|
# sops secret factory: name == key by default, owner/group default to root.
|
||||||
|
# `format` and `sopsFile` default to yaml + defaultSopsFile, matching every
|
||||||
|
# pre-existing caller.
|
||||||
mkSecret =
|
mkSecret =
|
||||||
{
|
{
|
||||||
path,
|
path,
|
||||||
@@ -16,14 +18,16 @@ let
|
|||||||
key ? null,
|
key ? null,
|
||||||
owner ? null,
|
owner ? null,
|
||||||
group ? null,
|
group ? null,
|
||||||
|
format ? "yaml",
|
||||||
|
sopsFile ? null,
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
format = "yaml";
|
inherit format path mode;
|
||||||
inherit path mode;
|
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (key != null) { inherit key; }
|
// lib.optionalAttrs (key != null) { inherit key; }
|
||||||
// lib.optionalAttrs (owner != null) { inherit owner; }
|
// lib.optionalAttrs (owner != null) { inherit owner; }
|
||||||
// lib.optionalAttrs (group != null) { inherit group; };
|
// lib.optionalAttrs (group != null) { inherit group; }
|
||||||
|
// lib.optionalAttrs (sopsFile != null) { inherit sopsFile; };
|
||||||
|
|
||||||
# default owner = device user
|
# default owner = device user
|
||||||
mkUserSecret =
|
mkUserSecret =
|
||||||
@@ -37,8 +41,6 @@ let
|
|||||||
);
|
);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
xlib.device.username = "oqyude";
|
|
||||||
|
|
||||||
users = {
|
users = {
|
||||||
mutableUsers = false;
|
mutableUsers = false;
|
||||||
users = {
|
users = {
|
||||||
@@ -46,10 +48,31 @@ in
|
|||||||
name = "${user}";
|
name = "${user}";
|
||||||
isNormalUser = true;
|
isNormalUser = true;
|
||||||
group = "users";
|
group = "users";
|
||||||
|
# Pinned, not left to NixOS' nextfree logic: the ntfs3/exfat mount
|
||||||
|
# helpers (lib/xlib/helpers.nix) bake xlib.device.uid into their mount
|
||||||
|
# options, so normally both sides agree and NTFS/exFAT files do not
|
||||||
|
# show up as owned by `nobody`. NixOS has no per-user `gid` option —
|
||||||
|
# the primary group id comes from `group` above.
|
||||||
|
#
|
||||||
|
# sapphira is the one exception, and only until its filesystem gets
|
||||||
|
# migrated: /var/lib/nixos/uid-map still reserves 1000 for the
|
||||||
|
# long-removed `yuyus` and NixOS never renumbers an existing user, so
|
||||||
|
# the live `oqyude` there is uid 1001. Without this branch a rebuild
|
||||||
|
# would rewrite the user to 1000 while every file is still owned by
|
||||||
|
# 1001. The cost: the exFAT mounts on sapphira still get uid=1000 from
|
||||||
|
# xlib.device.uid, so that user cannot write to /mnt/archive or
|
||||||
|
# /mnt/mobile until the id question is settled.
|
||||||
|
# TODO: delete this branch once sapphira is migrated to 1000.
|
||||||
|
uid = if xlib.device.hostname == "sapphira" then 1001 else xlib.device.uid;
|
||||||
description = "Jor Oqyude";
|
description = "Jor Oqyude";
|
||||||
hashedPasswordFile = config.sops.secrets.hashed_password.path; # hashed_password
|
hashedPasswordFile = config.sops.secrets.hashed_password.path; # hashed_password
|
||||||
homeMode = "700";
|
homeMode = "700";
|
||||||
home = "/home/${user}";
|
home = "/home/${user}";
|
||||||
|
# Linger keeps `user@<uid>.service` (the systemd user manager) alive
|
||||||
|
# across logouts, so user services like opencode-web survive when no
|
||||||
|
# SSH/login session is active. Without this the service is torn down
|
||||||
|
# together with the user manager on the last session close.
|
||||||
|
linger = true;
|
||||||
extraGroups = [
|
extraGroups = [
|
||||||
"audio"
|
"audio"
|
||||||
"disk"
|
"disk"
|
||||||
@@ -84,6 +107,38 @@ in
|
|||||||
path = "${xlib.dirs.user-home}/.config/sops/age/keys.txt";
|
path = "${xlib.dirs.user-home}/.config/sops/age/keys.txt";
|
||||||
mode = "0600";
|
mode = "0600";
|
||||||
};
|
};
|
||||||
|
# opencode web server creds + Gemini API key.
|
||||||
|
# Decrypted as a single dotenv file (no `key`) and consumed by the
|
||||||
|
# systemd user unit opencode-web as EnvironmentFile.
|
||||||
|
# Source: secrets/opencode.env (encrypted, see sops/age below).
|
||||||
|
# Path is shared with home/modules/opencode.nix via xlib.dirs so the
|
||||||
|
# sops materialization and the systemd EnvironmentFile can never
|
||||||
|
# silently desync.
|
||||||
|
opencode_server = mkUserSecret {
|
||||||
|
path = xlib.dirs.opencode-server-env;
|
||||||
|
mode = "0600";
|
||||||
|
format = "dotenv";
|
||||||
|
sopsFile = ../secrets/opencode.env;
|
||||||
|
};
|
||||||
|
# opencode provider credentials (XDG_DATA_HOME/opencode/...).
|
||||||
|
# Both files are read by opencode at startup to populate the providers
|
||||||
|
# list. Mirror of ~/.local/share/opencode/ on the workstation.
|
||||||
|
# key = "" → decrypt the WHOLE file as-is (the JSON has no top-level
|
||||||
|
# field named after the secret; it IS the secret).
|
||||||
|
opencode_auth = mkUserSecret {
|
||||||
|
path = "${xlib.dirs.user-home}/.local/share/opencode/auth.json";
|
||||||
|
mode = "0600";
|
||||||
|
format = "json";
|
||||||
|
sopsFile = ../secrets/opencode-auth.json;
|
||||||
|
key = "";
|
||||||
|
};
|
||||||
|
opencode_account = mkUserSecret {
|
||||||
|
path = "${xlib.dirs.user-home}/.local/share/opencode/account.json";
|
||||||
|
mode = "0600";
|
||||||
|
format = "json";
|
||||||
|
sopsFile = ../secrets/opencode-account.json;
|
||||||
|
key = "";
|
||||||
|
};
|
||||||
ssh_key_private = mkUserSecret {
|
ssh_key_private = mkUserSecret {
|
||||||
path = "${xlib.dirs.user-home}/.ssh/id_ed25519";
|
path = "${xlib.dirs.user-home}/.ssh/id_ed25519";
|
||||||
mode = "0600";
|
mode = "0600";
|
||||||
|
|||||||
@@ -11,11 +11,10 @@
|
|||||||
./systemd.nix
|
./systemd.nix
|
||||||
# ./glances.nix
|
# ./glances.nix
|
||||||
# ./netbird.nix
|
# ./netbird.nix
|
||||||
# ./xray.nix
|
|
||||||
];
|
];
|
||||||
# VDS hosts the public-facing Xray REALITY inbound on container:443,
|
# VDS hosts the public-facing Xray REALITY inbound on container:443,
|
||||||
# fronted by nginx stream on host:443 → host:15380 → container:443.
|
# fronted by nginx stream on host:443 → host:15380 → container:443.
|
||||||
xlib.services."3x-ui" = {
|
host."3x-ui" = {
|
||||||
certDomain = "pubray1.zeroq.su";
|
certDomain = "pubray1.zeroq.su";
|
||||||
reality443Forwarding = true;
|
reality443Forwarding = true;
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-20
@@ -7,7 +7,7 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
serviceName = "rsync-services-sync";
|
serviceName = "rsync-services-sync";
|
||||||
serverAddress = "oqyude@100.64.0.0";
|
serverAddress = "${xlib.device.username}@100.64.0.0";
|
||||||
serverDir = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}";
|
serverDir = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}";
|
||||||
nodeDir = "${xlib.dirs.services-mnt-folder}";
|
nodeDir = "${xlib.dirs.services-mnt-folder}";
|
||||||
in
|
in
|
||||||
@@ -19,7 +19,7 @@ in
|
|||||||
unitConfig.RequiresMountsFor = [ ];
|
unitConfig.RequiresMountsFor = [ ];
|
||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
# Только по таймеру: НЕ блокируем boot и активацию nixos-rebuild.
|
||||||
script = ''
|
script = ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -29,31 +29,21 @@ in
|
|||||||
SSH_CMD="$SSH \
|
SSH_CMD="$SSH \
|
||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
-o ConnectTimeout=5 \
|
-o ConnectTimeout=5 \
|
||||||
|
-o ServerAliveInterval=5 \
|
||||||
|
-o ServerAliveCountMax=3 \
|
||||||
-o StrictHostKeyChecking=no"
|
-o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
echo "Waiting for server..."
|
|
||||||
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
echo "Attempt $i"
|
|
||||||
|
|
||||||
if $SSH_CMD ${serverAddress} true >/dev/null 2>&1; then
|
|
||||||
echo "Server available"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
# final check
|
|
||||||
$SSH_CMD ${serverAddress} true >/dev/null 2>&1
|
|
||||||
|
|
||||||
mkdir -p "${nodeDir}"
|
mkdir -p "${nodeDir}"
|
||||||
|
|
||||||
|
# best-effort: сервер недоступен -> выходим сразу, никаких циклов ожидания
|
||||||
|
if ! $SSH_CMD ${serverAddress} true >/dev/null 2>&1; then
|
||||||
|
echo "Server ${serverAddress} unreachable, skipping sync"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ! -d "${nodeDir}" ] || [ -z "$(ls -A "${nodeDir}")" ]; then
|
if [ ! -d "${nodeDir}" ] || [ -z "$(ls -A "${nodeDir}")" ]; then
|
||||||
echo "Pull <- ${serverAddress}"
|
echo "Pull <- ${serverAddress}"
|
||||||
|
|
||||||
mkdir -p "${nodeDir}"
|
|
||||||
|
|
||||||
$RSYNC \
|
$RSYNC \
|
||||||
-e "$SSH_CMD" \
|
-e "$SSH_CMD" \
|
||||||
-a \
|
-a \
|
||||||
@@ -77,6 +67,9 @@ in
|
|||||||
Nice = 10;
|
Nice = 10;
|
||||||
CPUQuota = "5%";
|
CPUQuota = "5%";
|
||||||
IOSchedulingClass = "idle";
|
IOSchedulingClass = "idle";
|
||||||
|
# страховка от зависшего rsync/ssh; легитимная большая синхронизация
|
||||||
|
# укладывается в это окно (на фоне idle-приоритета)
|
||||||
|
TimeoutStartSec = 600;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
config,
|
|
||||||
inputs,
|
|
||||||
pkgs,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
{
|
|
||||||
services.xray = {
|
|
||||||
enable = false;
|
|
||||||
settings = inputs.zeroq-credentials.services.xray.config;
|
|
||||||
};
|
|
||||||
|
|
||||||
# networking.firewall = {
|
|
||||||
# allowedTCPPorts = [
|
|
||||||
# 8443
|
|
||||||
# 9443
|
|
||||||
# 13380
|
|
||||||
# ];
|
|
||||||
# allowedUDPPorts = [
|
|
||||||
# 8443
|
|
||||||
# 9443
|
|
||||||
# 13380
|
|
||||||
# ];
|
|
||||||
# };
|
|
||||||
|
|
||||||
environment.systemPackages = [ pkgs.xray ];
|
|
||||||
|
|
||||||
# sops.secrets = {
|
|
||||||
# xray_uuid = {
|
|
||||||
# key = "uuid";
|
|
||||||
# mode = "0444";
|
|
||||||
# format = "yaml";
|
|
||||||
# sopsFile = ./secrets/xray.yaml;
|
|
||||||
# path = "/etc/xray/uuid";
|
|
||||||
# };
|
|
||||||
# xray_private-key = {
|
|
||||||
# path = "/etc/xray/private-key";
|
|
||||||
# key = "private-key";
|
|
||||||
# mode = "0444";
|
|
||||||
# format = "yaml";
|
|
||||||
# sopsFile = ./secrets/xray.yaml;
|
|
||||||
# };
|
|
||||||
# };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# WSL NixOS — advertise this host as a remote Nix builder.
|
||||||
|
#
|
||||||
|
# Why a dedicated module instead of inlining into configurations/wsl.nix:
|
||||||
|
# every "what makes this WSL different from a desktop/server" concern
|
||||||
|
# belongs under modules/wsl/ — that is the contract the device-type import in
|
||||||
|
# modules/default.nix wires up. Keeping it here means flipping the feature on
|
||||||
|
# later on another WSL host (e.g. a future vetymae-2) is one import away.
|
||||||
|
#
|
||||||
|
# The `host.builder.enable` option itself is declared in
|
||||||
|
# modules/options.nix (cross-module).
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
config = lib.mkIf config.host.builder.enable {
|
||||||
|
# WSL2 does not expose /dev/kvm to the guest (no nested virt by default,
|
||||||
|
# and Hyper-V's /dev/kvm is not bind-mounted into the WSL namespace).
|
||||||
|
# The default NixOS module advertises `kvm nixos-test benchmark
|
||||||
|
# big-parallel` as this host's system-features, which is a lie: any
|
||||||
|
# derivation that requires `kvm` will be dispatched here and immediately
|
||||||
|
# fail with "cannot open /dev/kvm". Nix selects builders by matching the
|
||||||
|
# derivation's required features against what the builder advertises, so
|
||||||
|
# the only way to keep WSL useful is to retract the features it cannot
|
||||||
|
# actually deliver. `nixos-test` is dropped for the same reason — it
|
||||||
|
# wants kvm anyway.
|
||||||
|
#
|
||||||
|
# `mkForce` because the NixOS module base-config sets a non-empty
|
||||||
|
# default; without force the lists would concatenate and the WSL would
|
||||||
|
# *still* advertise kvm.
|
||||||
|
nix.settings.system-features = lib.mkForce [
|
||||||
|
"benchmark"
|
||||||
|
"big-parallel"
|
||||||
|
];
|
||||||
|
|
||||||
|
# Builds arrive over SSH as the user `oqyude` (see
|
||||||
|
# modules/server/builder.nix). On the default trusted-users = ["root"]
|
||||||
|
# only root can call nix-store, so the SSH session would fail to realise
|
||||||
|
# any .drv. Adding the SSH user to trusted-users lets the remote nix-build
|
||||||
|
# driver drive nix-store on the builder side. `mkForce` for the same
|
||||||
|
# concatenation reason as above.
|
||||||
|
nix.settings.trusted-users = lib.mkForce [
|
||||||
|
"root"
|
||||||
|
"oqyude"
|
||||||
|
];
|
||||||
|
|
||||||
|
# The local daemon already parallelises across all 24 logical cores
|
||||||
|
# (max-jobs = 24 is what we measured). When acting as a builder, we
|
||||||
|
# want to keep that — remote builds land through SSH and the daemon
|
||||||
|
# serves them on top of its normal pool. No override needed; documented
|
||||||
|
# here so a future reader does not "tidy up" by setting max-jobs low.
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user