Skip to content

Instantly share code, notes, and snippets.

@litnimax
Last active August 5, 2026 18:12
Show Gist options
  • Select an option

  • Save litnimax/82c025b278a3f0614cccd3b0acebf7b0 to your computer and use it in GitHub Desktop.

Select an option

Save litnimax/82c025b278a3f0614cccd3b0acebf7b0 to your computer and use it in GitHub Desktop.
Архитектура Angee — полный source audit: I. angee-django (2026-08-03) · II. angee-operator (2026-08-05)

Архитектура Angee Django: полный разбор по исходному коду

Самостоятельный source audit репозитория angee-django. Это не пересказ README и не каталог продуктовых экранов: документ восстанавливает архитектуру системы снизу вверх — от загрузки проекта и компиляции addon’ов до ORM, REBAC, GraphQL, фоновых процессов, generated runtime и React-приложения.

0. Паспорт анализа

Параметр Значение
Репозиторий /Users/max/Dev/angee-django
Ветка main
Commit 4f6e8235721f5e0d12266f425f6b665d3b972ece (sync fixes)
Дата commit 2026-07-17
Дата анализа 2026-08-03
Версия пакета 0.1.7
Лицензия AGPL-3.0-or-later
Статус самого проекта early alpha / active refactor
Backend floor Python 3.14+, Django 6.0+
Frontend floor Node 22.13+, pnpm 11.1.3, React 19, TypeScript 6, Vite 8

Что именно просмотрено

Инвентаризированы все 1 682 отслеживаемых Git файла. Архитектурно значимые реализации прочитаны по слоям: корневой bootstrap, angee/base, angee/compose, angee/graphql, angee/tasks, четыре frontend-пакета, manifests и ключевые реализации всех поставляемых addon’ов, шаблоны, example host и тестовые architectural guardrails. Тесты использованы не как вторичная документация, а как исполняемое описание инвариантов.

Количественный профиль репозитория:

Область Файлов Приблизительный объём
angee/ 543 14 151 строк Python в core; около 77 800 строк TS/TSX вместе с frontend-тестами
addons/ 713 около 51 345 строк Python и 34 100 строк TS/TSX
tests/ 142 около 54 590 строк Python
packages/ 92 около 6 100 строк TS/TSX
examples/ 68 около 2 100 строк Python и 2 050 строк TS/TSX
templates/ 49 project/stack/workspace/addon/service templates
Всего Python 122 198 строк
Всего TS + TSX около 120 200 строк
REBAC-схемы 26 .zed файлов 1 381 строк

Набор тестов крупнее, чем кажется по числу файлов: найдено примерно 1 558 Python test-функций/методов и 1 064 вызова test(...)/it(...) во frontend-тестах. Это важно: значительная часть архитектуры Angee закреплена отрицательными тестами — что один слой не имеет права импортировать или делать.


1. Короткий ответ: что такое Angee

Angee Django — это компилятор модульного приложения поверх Django, дополненный единым data/API/UI-контрактом.

На входе находятся:

  • проект с корневыми addon’ами в INSTALLED_APPS;
  • addon manifests addon.toml;
  • абстрактные source models;
  • GraphQL contributions;
  • REBAC-схемы;
  • декларативные resources;
  • React addon manifests.

На build-этапе Angee:

  1. строит транзитивный и детерминированный граф addon’ов;
  2. объединяет настройки;
  3. композирует Python-классы моделей;
  4. генерирует concrete Django runtime и migration ownership;
  5. объединяет permission schemas;
  6. собирает именованные GraphQL schemas;
  7. извлекает machine-readable metadata о ресурсах;
  8. генерирует SDL, typed GraphQL documents и статический React composition root.

На выходе работает обычное, хотя и сгенерированное, приложение:

  • Django ORM и migrations;
  • Strawberry GraphQL в Hasura-совместимом диалекте;
  • relationship-based authorization через django-zed-rebac;
  • Channels/Redis subscriptions;
  • Celery workers;
  • React + Refine + TanStack Router UI;
  • FastMCP surface для агентов.

Главный архитектурный принцип репозитория формулируется как framework owns seams, not concerns. Django владеет ORM и lifecycle, Strawberry — GraphQL, REBAC-библиотека — authorization semantics, Refine — data hooks/cache, React — rendering. Angee владеет местами их соединения, детерминированностью композиции и общим метаконтрактом.

flowchart TB
    P["Project settings\nroot addon declarations"]
    A["Addon sources\nmodels · schema · permissions · resources · web"]
    C["Angee build-time composer"]
    R["Generated runtime\nmodels · migrations · permissions · SDL · metadata"]
    B["Django backend\nORM · REBAC · GraphQL · Tasks"]
    W["React application\nRefine · Metadata · UI · addon manifests"]
    M["Humans · services · agents"]

    P --> C
    A --> C
    C --> R
    R --> B
    R --> W
    B <--> W
    B --> M
    W --> M
Loading

Это принципиально не runtime plugin registry в стиле «загрузили модуль и пропатчили глобальное состояние». Входные addon’ы компилируются в одну проверяемую систему; ошибка композиции должна проявиться до обслуживания пользовательского трафика.


2. Полная карта логических слоёв

Логический слой Физические владельцы Что он делает Что он не должен делать
0 Operator / окружение внешний angee-operator, angee.yaml, templates checkout sources, workspace/stack, процессы, containers, ports, secrets injection не определяет Django-модели или бизнес-схему
1 Project contract angee/project.py, angee/compose/project.py находит проект, читает .env и settings, задаёт runtime dir не содержит доменную модель
2 Addon contract angee/addons.py, addon.toml единый manifest: deps, migrations, resources, schemas, permissions, web, MCP не выполняет произвольную runtime-регистрацию
3 Dependency composition angee/compose/apps.py, composer.py, autoconfig.py dependency closure, topological order, settings fold, ownership checks не обслуживает requests
4 Source-model language angee/base/models.py, fields.py, mixins.py, addon models.py абстрактные декларации модели и расширений не является финальным Django table graph
5 Runtime compiler angee/compose/runtime.py, migrations.py, permissions.py объединяет классы и схемы, пишет generated runtime не допускает ручное редактирование output
6 Concrete persistence generated runtime/*/models.py, Django migrations, PostgreSQL/SQLite реальные tables, constraints, transactions, managers не определяет UI-представление
7 Identity and authorization angee/base, angee.iam, .zed, django-zed-rebac actors, roles, tuples, row/field scope, create gates не полагается на client-side guards
8 Declarative resources angee.resources master/install/demo data, xrefs, adoption, grants, ledger не смешивает network fetching с core loader
9 API composition angee/graphql + addon schema.py schema buckets, CRUD, filters, aggregates, actions, deletion, revisions не импортирует build-time composer
10 Realtime GraphQL changes/subscriptions, Channels transaction-safe publication и read-gated delivery не выдаёт event без повторной проверки доступа
11 HTTP/ASGI angee/urls.py, angee/asgi.py, IAM middleware URL contributions, GraphQL HTTP/WS, FastMCP mounts, lifespan не заново строит addon graph
12 Background execution angee/tasks, Celery, addon tasks queues, retries, periodic jobs, local/advisory locks не подменяет transaction semantics ORM
13 Backend→frontend contract SDL + *.metadata.json capabilities, field kinds, roots, relations, groups, actions не требует ручного дублирования модели в TS
14 Web build/codegen runtime web manifest, angee-web-codegen typed documents, imports addon packages, emits app.ts не угадывает schema по имени или UI-коду
15 Frontend data/runtime @angee/metadata, @angee/refine, @angee/app metadata projection, transport, cache/live, routes/providers не реализует backend permissions
16 Rendered UI @angee/ui, addon web packages generic views/widgets/chrome и доменные расширения не владеет wire dialect
17 Verification pytest, Vitest, Playwright, Storybook, CI guardrails доказывает направление зависимостей и contracts не служит запасной production-реализацией

Направление зависимостей намеренно одностороннее. base не импортирует compose, GraphQL или addon’ы; GraphQL и resources не импортируют compose; стабильные serving entrypoints не зависят от build-time layer. На frontend @angee/refine и @angee/metadata — нижние независимые слои, @angee/ui может использовать их, а @angee/app является верхним composition root.


3. Физическая структура репозитория

angee/: framework core

  • angee/addons.py — parser и resolver addon contracts.
  • angee/project.py, angee/paths.py, angee/fs.py — project/root/path primitives.
  • angee/compose/ — build-time compiler: app graph, settings, runtime emission, runtime migrations, permission extension.
  • angee/base/ — нижний модельный toolkit: base model, managers, fields, mixins, transitions, public IDs, canonical record refs.
  • angee/graphql/ — runtime API and metadata layer.
  • angee/tasks/ — Celery app, enqueue API и distributed lock abstraction.
  • angee/urls.py, angee/asgi.py — стабильные serving entrypoints.
  • angee/web/{app,refine,metadata,ui} — четыре общих frontend-пакета, включаемых в Python wheel.

addons/angee/: base addon distribution

32 addon’а образуют поставляемый с framework базовый каталог. Каждый — вертикальный срез и может иметь:

  • addon.toml;
  • Python source models;
  • schema.py;
  • permissions.zed и иногда permissions.extends.zed;
  • resources/*.yaml;
  • tasks/backends/integration implementation;
  • web/package.json + React surface.

Core и base addons намеренно используют общий PEP 420 namespace angee.*. Это позволяет распространять framework и внешние addon distributions независимо, но видеть их как одну Python namespace family.

Остальные корни

  • packages/storybook — component workshop и visual development.
  • packages/e2e — Playwright harness, fixtures, page objects, role sessions.
  • examples/notes-angee — минимальный реальный Host, а не часть framework core.
  • templates/ — Copier-шаблоны project, workspace, stack, service и web addon.
  • tests/ — backend unit/integration/architecture suite.
  • .agents/ и AGENTS.md — policy-as-code для разработки людьми и агентами.

Distribution boundary

Важная особенность: React framework packages поставляются внутри Python distribution django-angee. Build backend hatch-angee:

  • обнаруживает addon manifests;
  • генерирует angee.addons entry points;
  • включает addon web sources в wheel;
  • сохраняет одну версию и один release channel для Python и TypeScript контракта.

Dev-only packages, example project и operator остаются за этой границей.


4. Project discovery и bootstrap

4.1 Поиск проекта

angee/project.py ищет project root в строго заданном порядке:

  1. ANGEE_PROJECT_DIR;
  2. каталог рядом с вызывающим manage.py;
  3. ближайший ancestor с settings.yaml или settings.py.

Это не косметика. Один из рисков YAML-based settings — случайно захватить конфигурацию родительского workspace. Загрузчик делает поиск ограниченным найденным project boundary.

4.2 ProjectContract

angee.compose.project.ProjectContract:

  • загружает .env;
  • выбирает Python settings module либо создаёт synthetic module для YAML project;
  • применяет безопасный default floor;
  • добавляет project root и addon source dirs в sys.path;
  • передаёт нормализованный проект Composer.

Default development floor включает SQLite, WAL/IMMEDIATE transaction behavior, data/media/static/runtime directories. Deployment может передать DATABASE_URL, CACHE_URL, EMAIL_URL; явная project setting имеет приоритет над default, а объявленная environment variable — над addon default.

4.3 Что принадлежит Composer

Composer резервирует и сам задаёт настройки, которые определяют целостность приложения:

  • финальный INSTALLED_APPS;
  • ROOT_URLCONF;
  • ASGI_APPLICATION;
  • runtime module/path;
  • migration-module redirection;
  • merged REBAC schema locations.

Addon, пытающийся переопределить reserved key через autoconfig.py, получает fail-fast ошибку. Это предотвращает ситуацию, когда один plugin незаметно подменил системный entrypoint.

sequenceDiagram
    participant CLI as manage.py / ASGI
    participant PC as ProjectContract
    participant AG as AppGraph
    participant AC as AutoConfig
    participant CO as Composer
    participant DJ as Django app registry

    CLI->>PC: locate project + load env/settings
    PC->>AG: root INSTALLED_APPS
    AG->>AG: resolve addon dependency closure
    AG->>AG: validate aliases, cycles, missing deps
    AG-->>PC: stable topological AppConfig order
    PC->>AC: fold addon settings in order
    AC-->>PC: composed settings
    PC->>CO: own runtime, URLs, ASGI, migrations
    CO->>DJ: populate composed app registry
Loading

5. Addon contract и граф зависимостей

5.1 Manifest как единственная точка объявления

AddonContract в angee/addons.py читает colocated addon.toml. Само наличие manifest означает «это Angee addon». Контракт содержит:

  • полное Python имя и project metadata;
  • depends_on;
  • addon-owned runtime migrations;
  • именованные GraphQL schema contributions;
  • permission schema;
  • web package и external web codegen declarations;
  • MCP tool registrar;
  • tiered resource entries.

Часть полей выводится по конвенции без импортирования runtime-кода:

  • schema.py с top-level schemas;
  • permissions.zed;
  • web/package.json;
  • mcp_tools.py:register.

Conventions проверяются через filesystem/AST, поэтому dependency discovery не обязан преждевременно импортировать Django models.

5.2 Обнаружение

Доступные addon’ы объединяются из двух источников:

  • installed Python entry points группы angee.addons;
  • локальных package directories с addon.toml.

Так одновременно поддерживаются опубликованные wheels и editable/local workspace sources.

5.3 AppGraph

Проект перечисляет только root addons. AppGraph рекурсивно добавляет depends_on, создаёт/находит Django AppConfig, а затем выполняет стабильную topological sort.

Инварианты:

  • неизвестная dependency — ошибка;
  • цикл — ошибка с цепочкой;
  • duplicate app alias — ошибка;
  • порядок не зависит от недетерминированного обхода filesystem;
  • root/explicit и forced/dependency app различимы метаданными;
  • dependency list сортируется перед обходом.

Это различие root vs forced нужно, например, для platform UI: обязательная техническая dependency установлена, но не обязана считаться сознательно выбранной пользовательской capability.

5.4 Addon contribution seam

Для runtime surfaces используется узкий helper addon_contribution: он загружает только convention-based contribution конкретного уже установленного addon’а, например URL patterns или websocket routes. Serving layer читает готовый Django app registry, а не повторяет build composition.


6. Автоконфигурация: fold, а не скрытая магия

Addon может предоставить autoconfig.py:

  • константу SETTINGS;
  • опциональную функцию settings(namespace).

AutoConfig применяет fragments в dependency order. Семантика следует django-yamlconf: поддерживаются append и dotted keys. Приоритеты устроены так:

  1. framework floor;
  2. dependency addon defaults;
  3. dependent addon defaults;
  4. явные project settings;
  5. объявленные environment overrides.

Plain addon default не должен перезаписывать уже заданное project value. В отличие от Odoo-style runtime context, результат — обычный Django settings namespace, полностью сформированный до normal runtime.

Настройки также являются registry seam. Например, ImplClassField хранит не class path, а стабильный implementation key, который разрешается через settings-backed registry. Addon добавляет реализацию декларативно; модель хранит выбранную стратегию без импортирования integration package.


7. Source model language

7.1 Почему модели в addon’ах абстрактные

AngeeModel — abstract Django model. Source model описывает вклад addon’а в будущую concrete model graph, а не обязательно отдельную таблицу.

Ключевые декларации:

  • runtime=True — материализовать source model как concrete runtime model;
  • extends="app.Model" — расширить уже объявленную модель;
  • runtime=True вместе с extends — создать настоящий Django multi-table child;
  • child_overrides_parent=True — в строго ограниченном случае изменить MRO так, чтобы child implementation имела приоритет.

Обычное расширение extends — это same-row composition: поля и поведение нескольких abstract classes объединяются в одном concrete class/table. Это центральный механизм модульности Angee.

7.2 Правила владения

Runtime scanner рассматривает только abstract AngeeModel classes, принадлежащие сканируемому addon package. Он разделяет:

  • owned runtime model sources;
  • extensions/donors;
  • runtime children;
  • служебные abstract mixins.

Модель идентифицируется итоговым Django label. Две source declarations не могут независимо владеть одним emitted label. Прямые field collisions запрещены: система не пытается угадывать, какой author «победил».

7.3 Class composition

Для каждой emitted model compiler строит bases из:

  1. extension donors в детерминированном порядке;
  2. concrete runtime parent, если это MTI child;
  3. исходного owner class.

Затем генерирует Meta с concrete status и app label, переносит REBAC facts и добавляет contribution artifacts от mixins:

  • ModelDecorator;
  • ModelClassAttribute.

Так, например, history/revision integration может регистрироваться на финальном composed class, а не на исходном abstract donor.

7.4 Ограниченный override

child_overrides_parent не является общей лазейкой для произвольного MRO. Compiler проверяет:

  • child действительно materialized;
  • parent relationship — реальное MTI;
  • manager semantics совместимы;
  • state transition metadata остаётся валидной;
  • shadowed fields эквивалентны.

Это один из примеров философии Angee: расширение разрешено только через узкую, проверяемую seam.


8. Generated runtime

8.1 Что генерируется

angee/compose/runtime.py эмитит project-local runtime tree:

runtime/
├── __init__.py
├── <app_label>/
│   ├── __init__.py
│   ├── models.py
│   └── migrations/
├── permissions/
│   └── ... merged Zed schemas ...
├── schemas/
│   ├── console.graphql
│   ├── console.metadata.json
│   └── ...
├── gql/
│   └── <schema>/... generated TypedDocumentNode files
└── web/
    ├── manifest.json
    └── app.ts

Generated source — build artifact. Его нельзя редактировать вручную.

8.2 Почему emission происходит так рано

ComposeConfig.import_models() подключается к phase 2 Django app population. До того как Django завершит импорт моделей, Angee:

  1. сканирует source declarations;
  2. рассчитывает expected runtime;
  3. пишет отсутствующие/устаревшие generated files;
  4. импортирует concrete runtime model modules.

Это нужно, чтобы standard Django mechanisms — swappable user model, relations, checks, migrations — видели уже финальные concrete classes.

8.3 Drift и очистка

Compiler сначала строит in-memory map path → rendered content, затем сравнивает с disk. Запись:

  • атомарна;
  • не трогает unchanged file;
  • детерминирована при одинаковых входах.

Обычный boot может дописать stale expected files, но не делает широкую destructive cleanup. Явный manage.py angee build имеет право удалить orphan generated files, только если runtime tree содержит специальный generated sentinel. Migration directories при очистке сохраняются.

angee build --check превращает drift в CI failure без изменения output.

8.4 Fresh-interpreter boundary

После изменения generated model graph нельзя продолжать provisioning в том же Python процессе: старые классы уже импортированы в Django registry. Поэтому provision orchestration запускает следующие этапы в новых интерпретаторах.

sequenceDiagram
    participant P as provision
    participant B as angee build
    participant R as generated runtime
    participant M as Django migrations
    participant Z as REBAC sync
    participant D as resources loader
    participant S as GraphQL schema emitter

    P->>P: wait for database
    P->>B: compile models/permissions/web manifest
    B->>R: atomic deterministic output
    P->>P: start fresh interpreter
    P->>M: makemigrations + migrate
    P->>P: start fresh interpreter
    P->>Z: reconcile permission schema
    P->>D: load master/install/demo tiers
    P->>S: emit SDL + metadata
    P->>P: optional bootstrap admin
Loading

9. Runtime migrations: migrations принадлежат addon’у, номера — runtime

Обычная Django migration привязана к конкретному source app package. В Angee итоговая table graph появляется после композиции, поэтому addon-owned migration declaration отделена от generated runtime migration history.

Manifest migration содержит:

  • стабильное имя;
  • target runtime app;
  • Python module с source migration;
  • predicate applies(project_state).

RuntimeMigrations:

  1. читает текущий Django migration graph;
  2. определяет leaf и следующий номер в target runtime app;
  3. разрешает __latest__ dependencies;
  4. materializes source migration в generated migration file;
  5. записывает origin и SHA digest;
  6. отказывается молча принимать изменившийся уже materialized source.

Защиты включают cycles, multiple leaves, missing targets, corrupt provenance и digest mismatch. Таким образом addon владеет намерением миграции, а конкретная нумерация принадлежит composed project history.

MIGRATION_MODULES перенаправляется на generated runtime apps. Это позволяет обычным Django makemigrations, migrate и migration graph работать поверх итоговой модели.


10. Нижний модельный toolkit (angee.base)

10.1 Managers и QuerySets

  • AngeeQuerySet наследует RebacQuerySet: row scoping и field redaction являются default behavior.
  • AngeeManager соединяет Django manager API с REBAC semantics.
  • AngeeUnscopedQuerySet/Manager предназначены для моделей, которые сознательно не являются permission resources.
  • aggregate_scoped_queryset применяет row scope, но отключает field-redaction machinery для aggregate rows/dictionaries, где нет model instances.
  • read_scoped_queryset и write_scoped_queryset дают явные операции получения разрешённого набора.

10.2 Base models

  • AngeeModel — abstract timestamped REBAC-capable source base и язык runtime composition.
  • AngeeDataModel — типовой public data base с Sqid identity.
  • SqidPublicIdentity и helpers кодируют/декодируют external IDs.
  • role_anchor() объявляет стабильные authorization anchors.

10.3 Поля

  • SqidField — opaque public ID, основанный на primary key, с canonical prefix.
  • StateField — enum-aware field для transition engine.
  • EncryptedField — Fernet encryption at rest; invalid ciphertext не маскируется под нормальное значение.
  • ImplClassField — implementation selection по settings registry key.
  • PatternOpsIndex — PostgreSQL pattern-ops index с корректным fallback на других backends.

10.4 Mixins

Mixin Назначение
TimestampMixin created/updated timestamps и корректировка update_fields для auto_now
SqidMixin opaque public identity
AuditMixin authored/modified actor references
ArchiveMixin soft archive vocabulary и queryset helpers
HistoryMixin historical tracking contract
RevisionMixin django-reversion snapshot/revert integration
HierarchyMixin materialized path и subtree queries
RecordRefMixin generic canonical record reference
ImplDefaultsMixin default implementation selection

HierarchyMixin сознательно использует materialized path: prefix filter естественно проходит через Hasura _bool_exp и REBAC queryset scoping. Это пример выбора data representation исходя из сквозного контракта, а не только удобства ORM.

10.5 StateTransitions

@transition и StateTransitions реализуют:

  • допустимые source/target states;
  • conditions;
  • named actions;
  • on_success hooks;
  • protected direct writes в state field;
  • optimistic concurrency check;
  • save_state;
  • explicit force_state(reason=...);
  • revalidation metadata после class composition.

Transition policy умеет зависеть от REBAC permission query. Поэтому generic FSM library не используется: Angee нужен guard, выражаемый как permission/scoped query и заново валидируемый на final composed class.

10.6 Canonical record identity

Generic relations особенно сложны при Django multi-table inheritance. canonical_record_target выбирает верхнего подходящего REBAC-typed ancestor как стабильную цель записи. Для чтения/grants ancestor_object_refs разворачивает все фактические identities child→parents.

Это предотвращает расхождение, когда attachment/tag/activity записан на child type, а permission relation или foreign key ожидает parent type.

10.7 Остальные primitives

  • json_safe() детерминированно приводит Decimal/date/UUID/model-related values к JSON boundary.
  • quantize(value, places, mode) требует явный rounding mode; финансовое округление не оставлено ambient Decimal context.
  • actor resolver превращает текущий REBAC subject в user FK, поддерживая дополнительные non-user actor resolvers.
  • ContextVar sync_ingestion_context() отмечает записи, пришедшие из integration ingestion pipeline.

11. REBAC: authorization встроена в доступ к данным

11.1 Модель безопасности

Angee использует Zanzibar-shaped relationship authorization через django-zed-rebac. Разрешения описываются .zed schemas, а связи хранятся как relationship tuples:

resource <- relation <- subject[#optional_relation]

Actor может быть человеком, service identity или агентом. UI-проверки — только presentation; реальные row и field gates находятся на сервере.

11.2 Default behavior

Base autoconfig задаёт:

  • strict mode;
  • field-read behavior redact;
  • разрешённый sudo/system_context для внутреннего кода;
  • отсутствие автоматического superuser bypass;
  • local registry backend по умолчанию.

Даже platform administrator получает права через schema/role relationships, а не через неявную ветку if is_superuser: allow.

11.3 Read path

Typed model, требующая Angee REBAC contract, без actor’а fail-closes. QuerySet:

  • фильтрует недоступные rows;
  • redacts недоступные fields;
  • позволяет aggregate path использовать уже row-scoped query без instance field machinery.

GraphQL startup assertions проверяют, что exposed REBAC models имеют правильный manager. Это не соглашение «на честном слове».

11.4 Create path

Create невозможно проверить через существующий row, потому что row ещё нет. AngeeManager.check_create вызывает rebac.check_new с would-be relationships. GraphQL backend:

  1. декодирует relation IDs;
  2. строит будущие relationship contributions;
  3. выполняет create preflight;
  4. только затем использует узкий system context для самого insert.

Elevation ограничена одной уже авторизованной операцией; она не превращает весь request в sudo.

11.5 Update/delete

Update сначала находит target через write-scoped queryset. Затем проходят validation, transition/domain checks и save. Delete использует отдельный guarded deletion engine с preview и blocker semantics.

После physical delete generic signal удаляет как resource-side, так и subject-side relationship tuples. GC подключается к concrete composed models, а не только к abstract source.

11.6 Permission schema composition

Каждый addon обычно владеет своим permissions.zed. Для additive cross-addon extension используется отдельный permissions.extends.zed:

  • contributor может добавить relation в существующий definition;
  • может добавить arms к существующему permission;
  • не может незаметно заменить чужое определение.

Composer сортирует contributors, проверяет unknown target, unknown permission и collisions, добавляет provenance/revision, эмитит merged schema и перенаправляет owning AppConfig на неё. Пример — spaces расширяет permission vocabulary messaging thread.

11.7 Роли и IAM hub

angee.iam поверх raw REBAC schema строит:

  • user model и manager;
  • session authentication и bearer-aware CSRF middleware;
  • role anchors;
  • permission-source introspection;
  • role/grant/condition/schema view models для administration UI;
  • platform admin role;
  • bootstrap admin command.

Permission Hub не хранит вторую ACL-модель. Он анализирует активную REBAC schema и relationship rows, поэтому administrative view остаётся проекцией истинного authorization graph.


12. Declarative resources: системные данные как код

angee.resources — base addon, а не часть angee/compose. Он не импортирует composer и работает поверх уже populated Django registry.

12.1 Три tier’а

Tier Смысл
master каталог, принадлежащий source distribution; наиболее «замороженные» записи
install обязательные project/install данные
demo демонстрационные данные

Запрос более позднего tier автоматически включает prerequisites: demo означает master + install + demo.

12.2 Manifest entries и dependency graph

Resource declaration может указывать:

  • local path или зарегистрированный source type;
  • target model;
  • depends_on между файлами;
  • natural-key adopt fields;
  • publish hook;
  • kind rows или grants.

EntryGraph строит topological order и ловит missing dependencies/cycles. Поддерживаются YAML, JSON, CSV и TSV; structured files могут содержать envelope с _meta, default model и rows.

12.3 Xref ledger

Каждая управляемая запись получает внешний logical key _xref. Ledger Resource хранит:

  • source addon;
  • tier;
  • xref;
  • target model/public id;
  • content hash.

Загрузчик:

  1. валидирует headers и tier policy;
  2. одним запросом поднимает существующие ledger rows;
  3. вычисляет canonical SHA-256 контента;
  4. пропускает unchanged rows;
  5. разрешает FK/M2M через xrefs;
  6. adopts существующую запись по единственному natural key;
  7. создаёт/обновляет target;
  8. обновляет ledger;
  9. запускает addon post-load hooks.

Особо обработан случай stale sqid pointer после drop/recreate table и повторного использования PK: loader сверяет adopt identity и не перезаписывает случайно другую строку.

12.4 Grants как resources

kind="grants" загружает декларативные REBAC tuples. Ссылки могут быть:

  • literal typed ref;
  • публичный wildcard *;
  • xref на ранее загруженную model row.

Для MTI resource grant разворачивается на все REBAC identities, которые несёт child. Запись idempotent: существующие natural tuple keys не трогаются и не создают лишний audit/zookie bump.

12.5 Source registry и separation of concerns

Core resources addon поставляет только безопасный addon-relative path source. Remote url source добавляет angee.integrate через settings registry. Поэтому loader не приобретает сетевую ответственность и SSRF surface только потому, что умеет читать fixtures.

CLI поддерживает validate, load, diff и выбор addon/tier. Provision использует этот же API.


13. GraphQL как composed data plane

13.1 Именованные schema buckets

Addon schema.py экспортирует mapping schemas, например public и console. Каждый bucket содержит SchemaParts:

  • query;
  • mutation;
  • subscription;
  • types;
  • extensions;
  • type_extensions;
  • input_extensions.

GraphQLSchemas обходит addon’ы в готовом app order, собирает contributions отдельно для каждого имени, дедуплицирует одинаковые Python objects, проверяет collision root fields и строит финальные root types.

Именование schema — не hardcoded special case. Новый schema bucket появляется из installed addon contributions и затем получает свой endpoint, SDL, metadata и typed client.

13.2 AngeeSchema

Финальная Strawberry schema использует:

  • RebacExtension;
  • Django optimizer;
  • Hasura-compatible configuration;
  • validation/error normalization;
  • angee.resources metadata в graphql-core schema extensions.

Startup/schema-build проверки:

  • Sqid prefixes уникальны;
  • exposed REBAC model использует scoped manager;
  • revision field list не раскрывает gated relation/field;
  • enum descriptions корректны;
  • root field collisions отсутствуют.

Ошибки Django/Pydantic validation преобразуются в стабильные GraphQL extensions: machine code, field errors и form/non-field errors. Missing actor и permission denial также имеют различимые codes.

13.3 AngeeNode и public identity

AngeeNode стандартизует id и display representation. На wire передаётся opaque Sqid с model prefix, а не database PK. Relation input decoder проверяет prefix/type и ищет объект в правильном scoped queryset.


14. Generic resources поверх Hasura dialect

Основной API factory — hasura_model_resource. Он использует strawberry-django-hasura для стандартной Hasura-shaped части и добавляет Angee-specific behavior.

14.1 Стандартная поверхность

Для модели могут появиться:

  • list query;
  • by-pk/detail query;
  • _aggregate;
  • _groups и _groups_count;
  • filter and order input types;
  • create/update/save/delete mutations;
  • delete preview;
  • revisions;
  • changes subscription.

Не все capabilities включаются автоматически. Factory рассчитывает их по model/schema declarations и записывает в metadata. UI не должен угадывать наличие delete/revisions/groups из формы имени GraphQL field.

14.2 Write backend

AngeeHasuraWriteBackend является policy boundary:

  • create вызывает check_new до insert;
  • update получает row из write-scoped queryset;
  • foreign keys и M2M IDs декодируются до elevation;
  • model full_clean() выполняется до commit;
  • delete делегируется guarded deletion;
  • nested lines применяются транзакционно.

14.3 Editable lines

HasuraLines реализует не набор независимых child mutations, а atomic desired-state save:

  1. lock parent row;
  2. проверить parent write permission;
  3. принять полный желаемый список lines;
  4. отделить create/update/delete;
  5. отвергнуть stale, duplicate или принадлежащие другому parent IDs;
  6. применить parent patch и child diff в одной transaction;
  7. вернуть свежий composed record.

Узкий system context используется после parent authorization, чтобы child write не зависел от временного существования relation tuple в середине операции.

14.4 Aggregates

Aggregate queryset всегда сначала проходит row scope. Field redaction для model instances здесь неприменима, поэтому используется отдельный aggregate-safe path. Метаданные перечисляют допустимые measures, а не разрешают произвольный SQL aggregate над любым полем.

14.5 Grouping dialect

Grouped resource имеет DDN/NDC-подобную форму:

<resource>_groups(
  group_by: [...!]!
  where: ...
  having: ...
  order_by: ...
  limit: Int
  offset: Int
): [<Resource>Group!]!

Каждая bucket row возвращает typed key и aggregate. Поддерживаются:

  • scalar dimensions;
  • relation axes с human label axis;
  • time extractions;
  • number ranges/granularities;
  • curated count/sum/avg/min/max measures;
  • having;
  • group ordering;
  • drill-down filter echo.

Backend metadata, generated operation documents и frontend group adapters обязаны изменяться вместе. Frontend-only grouping semantics архитектурно запрещены.

14.6 Computed resources

Помимо Django models существуют Pydantic row resources. Они дают list-like schema для вычисляемых/внешних данных, но всё равно объявляют server/client row model и metadata. Это escape hatch для данных без local table, не обход единого UI contract.

flowchart LR
    SM["Composed Django model"]
    HR["hasura_model_resource"]
    GS["Strawberry schema"]
    MD["angee.resources metadata"]
    SDL["Generated SDL"]
    CG["Typed documents"]
    RF["@angee/refine provider"]
    UI["Generic Resource UI"]

    SM --> HR
    HR --> GS
    HR --> MD
    GS --> SDL
    MD --> CG
    SDL --> CG
    CG --> RF
    MD --> UI
    RF --> UI
Loading

15. Metadata — главный backend/frontend IR

В терминах compiler architecture DataResourceMetadata — это промежуточное представление между backend model/schema и generic frontend. Именно оно делает Angee не просто GraphQL backend’ом с набором React screens.

Для каждого ресурса metadata описывает:

Identity

  • modelLabel;
  • canonical model label;
  • resource type;
  • public ID prefix/semantics;
  • record representation;
  • server/client row models.

GraphQL names

  • roots list/detail/aggregate/groups/groupsCount;
  • create/update/save/delete/deletePreview;
  • revisions/changes;
  • generated input/output type names.

Capabilities

  • list/detail;
  • filter/sort;
  • aggregate/group;
  • filter echo;
  • revisions;
  • create/update/save/delete/delete preview;
  • change stream.

Field metadata

Для каждого поля:

  • kind: scalar, enum, relation, list;
  • scalar type;
  • widget hint;
  • enum values;
  • relation target и label axis;
  • readable/filterable/sortable/aggregatable/groupable;
  • creatable/updatable/required;
  • line-resource role.

Analytical metadata

  • filter/order fields;
  • aggregate measures;
  • group dimensions и extractions;
  • default sort;
  • drilldown/filter mapping.

Composition

Addon contributions могут дополнять metadata на root surfaces. Merge идёт per model с collision checking. Это позволяет domain addon добавить display/action/UI-relevant fact к ресурсу, не копируя базовое описание.

Artifact сериализуется в runtime/schemas/<schema>.metadata.json, а также прикрепляется к runtime GraphQL schema extensions. @angee/metadata валидирует artifact при запуске frontend и превращает его в Refine resources, model/field lookups и UI contracts.

IR является одной из самых важных архитектурных идей Angee: модель объявлена один раз, а transport и rendered UI получают согласованную проекцию. Но это не означает, что весь доменный UX генерируется автоматически; addon web package всё ещё может определить custom routes, forms, widgets и views.


16. Actions, deletion, revisions и change stream

16.1 Typed actions

Domain action оформляется как mutation с унифицированным ActionResult:

  • ok;
  • message;
  • optional created/affected id;
  • validation_errors.

Action metadata содержит invalidation hints. Web codegen распознаёт action fields по фактической SDL shape, а не по названию, и создаёт TypedDocumentNode.

16.2 Guarded deletion

Deletion engine сначала строит preview:

  • cascade tree;
  • модели и counts удаляемых объектов;
  • SET_NULL/updated groups;
  • protected/restricted blockers;
  • root object label/public id.

Preview фильтруется через actor visibility: пользователь не получает side-channel список закрытых зависимостей. На confirm движок повторяет gate и возвращает реальные deleted/updated counts. Большие scope checks разбиваются на chunks.

16.3 Revisions

RevisionMixin использует django-reversion; composer регистрирует final concrete models. GraphQL revisions expose только явно разрешённые snapshot fields. Schema assertion запрещает configuration, через которую revision history раскрыл бы закрытое поле или небезопасную relation.

16.4 Change publication

После save/delete строится JSON-safe ChangePayload. Публикация выполняется через transaction.on_commit, поэтому subscriber не увидит событие откатившейся transaction. Signal вызывается robust mode, затем payload попадает в Channels group.

Model может выключить broadcasts_changes; ingestion context позволяет помечать или подавлять integration-owned поток по контракту.

16.5 Subscription read gate

При подписке actor фиксируется в subscription context. Для каждого payload ChangeReadGate заново проверяет:

  • существует ли/был ли доступен object identity;
  • row permission;
  • field visibility/redaction.

Долгоживущая async subscription закрывает старые DB connections. In-memory channel layer разрешён только в DEBUG или при явном opt-in; обычная distributed конфигурация выводится на Redis.


17. HTTP, ASGI и authentication boundary

17.1 URL composition

angee.urls — стабильный module. Он берёт urlpatterns contributions у уже установленных addon’ов и flatten’ит их в dependency order. Никакого повторного model/addon build на request path нет.

GraphQL endpoints имеют форму:

/graphql/<schema>/

Есть отдельный CSRF token endpoint /auth/csrf/.

17.2 ASGI composition

angee.asgi лениво bootstrap’ит composed settings, затем собирает:

  • Django HTTP app;
  • websocket URL patterns;
  • addon HTTP mounts;
  • lifespan contexts.

Если дополнительных protocol surfaces нет, возвращается обычный Django ASGI application. Иначе строится ProtocolTypeRouter для http, websocket, lifespan. HTTP mount dispatch использует longest-prefix rule. WebSocket завернут в AuthMiddlewareStack.

FastMCP ASGI applications требуют lifespan; общий router владеет AsyncExitStack и корректно открывает/закрывает lifespans всех mounts.

17.3 Session, bearer и CSRF

Browser использует session cookies. Mutation requests получают CSRF token и отправляют его через transport. Bearer-authenticated service request освобождается от session CSRF только middleware, которое фактически распознало bearer path; это не глобальный CSRF disable.

WebSocket connection params и HTTP headers строятся общим transport-auth layer. Logout/credential change корректно закрывает live connections.

17.4 Development SDL

Runserver override запускает Uvicorn с Django autoreload. В development reloader child может через ANGEE_DEV_SDL регенерировать stale SDL. Production serving path никогда не пишет schema artifacts.


18. Фоновые задачи и concurrency

18.1 Celery

angee.tasks создаёт Celery app из composed Django settings и включает autodiscovery. Framework API ставит named task с:

  • kwargs;
  • ETA;
  • queue;
  • expiry.

Defaults включают Redis broker/backend configuration, hard/soft time limits, ignore-result policy и beat schedule в data directory.

18.2 Lock abstraction

LockKey задаёт стабильную логическую блокировку. Backend выбирается по возможностям:

  • SQLite/test — in-process lock;
  • PostgreSQL — session advisory lock для cross-process coordination.

PostgreSQL key получается как стабильная пара int4 из hash, а не Python hash() с process randomization. Consumers могут проверить, поддерживает ли backend cross_process, и отказаться запускать distributed-sensitive worker на локальной имитации.

Session advisory locks предъявляют требования к connection pooling: lock привязан к DB session. Это явно отражённая граница, а не скрытая гарантия.

18.3 Где живёт orchestration

Framework предоставляет очередь и lock seam; domain lifecycle остаётся в addon’е. Например:

  • integration scheduler — в integrate;
  • external message ingestion — в connector addons;
  • workflow execution — в workflows;
  • agent inference tasks — в agents.

19. Frontend build pipeline

19.1 Backend-emitted manifest

manage.py angee build создаёт runtime/web/manifest.json с:

  • установленными addon web packages;
  • локальными source roots или package fallbacks;
  • GraphQL document roots;
  • external codegen entries;
  • schema version.

Python не генерирует вручную большое schema-shaped TypeScript API. Он генерирует manifest и backend-owned SDL/metadata; дальше frontend owner применяет стандартный GraphQL Code Generator.

19.2 angee-web-codegen

CLI из @angee/app:

  1. читает runtime manifest;
  2. находит все runtime/schemas/*.graphql — disk SDL является source of truth;
  3. определяет addon source dirs: project/runtime source root либо node_modules fallback;
  4. собирает operation documents из convention files;
  5. запускает GraphQL Codegen client preset;
  6. строит дополнительные action/aggregate/group/delete-preview/revision/save documents из SDL + metadata;
  7. генерирует runtime/web/app.ts.

Document routing:

  • documents.public.ts → public schema;
  • documents.ts и documents.<schema>.ts → соответствующая non-public schema;
  • external owner может задать свой glob, SDL и optional bare types module.

Live capability определяется фактом наличия Subscription root в SDL, а не именем schema.

19.3 Generated composition root

runtime/web/app.ts содержит статические imports всех addon entrypoints и schema artifacts, затем экспортирует:

export const composedAddons = [...] as const;
export const schemas = {
  console: {
    url: "/graphql/console/",
    metadata: consoleMetadata,
    operationDocuments: consoleDocuments,
    live: true,
  },
};

Это статическая build composition: bundler видит реальные imports, TypeScript — реальные types, а collision detection выполняется при сборке/старте composition root.

flowchart TB
    AM["addon.toml + web/package.json"]
    PM["runtime/web/manifest.json"]
    BS["GraphQL SDL + metadata.json"]
    WC["angee-web-codegen"]
    TD["TypedDocumentNode artifacts"]
    WA["runtime/web/app.ts"]
    CA["createApp(composedAddons, schemas)"]
    V["Vite bundle"]

    AM --> PM
    PM --> WC
    BS --> WC
    WC --> TD
    WC --> WA
    TD --> CA
    WA --> CA
    CA --> V
Loading

20. Четыре frontend framework packages

20.1 @angee/metadata: владелец projection contract

Этот пакет:

  • валидирует generated metadata artifact;
  • индексирует resources по exact model label и fallback type name;
  • предоставляет field/row/public-id helpers;
  • проецирует metadata в Refine resource declarations;
  • вычисляет capability-based access-control surface;
  • описывает lines, groups, invalidation и record subtitle.

Он не выполняет network requests и не рендерит views. Это чистый boundary между machine metadata и клиентскими consumers.

20.2 @angee/refine: transport и data semantics

Пакет адаптирует Refine/Hasura к Angee dialect:

  • schema-named data providers;
  • session или bearer auth;
  • CSRF headers;
  • GraphQL WebSocket lifecycle;
  • Hasura-default filters/order/pagination;
  • typed authored query/mutation hooks;
  • aggregates, groups, facets;
  • delete preview;
  • revisions;
  • atomic save/lines;
  • action execution;
  • metadata-directed query invalidation;
  • TanStack Router provider.

Wire semantics принадлежат здесь, а не generic UI components. Архитектурный frontend test отдельно запрещает импортировать authored GraphQL hooks из @angee/ui.

20.3 @angee/ui: rendered binding

Пакет владеет визуальным и interaction layer:

Design system

  • Tailwind semantic tokens;
  • Base UI headless primitives;
  • variants/class merge;
  • inputs, dialogs, popovers, menus, tabs, tooltips, tables, forms.

Application chrome

  • app rail;
  • top bar/menu;
  • sub-navigation;
  • user menu;
  • systray;
  • command palette/spotlight;
  • drawer rail;
  • breadcrumbs.

Layouts

  • public/console layouts;
  • workbench;
  • split panes;
  • drawer overlays;
  • primary pane context;
  • named slots.

Generic data views

  • ResourceList;
  • rows/table list;
  • grouped list;
  • form/detail view;
  • board;
  • calendar;
  • graph;
  • gallery;
  • tree;
  • dashboard/aggregate panels;
  • editable lines;
  • record actions;
  • deletion preview;
  • revisions;
  • chatter.

Widget system

Widget registry выбирает renderer/editor по field metadata. Есть scalar/text, boolean, enum, number, monetary, date/time, relation, M2M, JSON, Markdown/CodeMirror, color, status, progress и domain-specific overrides.

Runtime contracts

Context/registries несут уже composed definitions menus, widgets, forms, chatter tabs, slots, drawers, previews, icons и data providers.

Самые крупные hotspots показывают фактический центр сложности: FormView — более 2 200 строк, ResourceList — более 1 200, list body — около 1 700. То есть generic rendered binding уже является существенным framework subsystem, а не тонкой коллекцией кнопок.

20.4 @angee/app: composition root

defineAddon и defineBaseAddon задают web manifest addon’а. Возможные contributions:

  • routes;
  • menu items;
  • widgets;
  • i18n bundles;
  • icons;
  • forms;
  • chatter tabs;
  • slots;
  • previews;
  • drawers;
  • data providers.

composeAddons применяет явную collision policy:

Contribution Collision semantics
routes, menu IDs, widgets, icons, forms, providers, i18n keys, previews duplicate — fail
chatter tab ID later contribution overrides
slot item unique по (slot, id)
drawer unique по (edge, id)
ordering явный sequence, не случайный import order

createApp:

  1. композирует addon manifests;
  2. загружает schema metadata;
  3. создаёт named Hasura providers и live providers;
  4. создаёт один shared QueryClient;
  5. связывает Refine, React Query, TanStack Router и nuqs;
  6. монтирует auth/i18n/notifications/modals/runtime providers;
  7. строит root/layout/resource routes и guards;
  8. вычисляет menu/resource indexes.

Один QueryClient разделяется между Refine и auth route gate; иначе cache и identity state могли бы рассинхронизироваться.

20.5 Route graph

Route composer:

  • связывает parent/layout relationships;
  • детектирует path/id collisions;
  • сортирует детерминированно;
  • генерирует standard list/detail children через resourcePageRoutes;
  • поддерживает auth/public boundaries и safe redirect.

21. Base addons: полная карта без ухода в продуктовую детализацию

Base addon’ы — не все одинаково «ядровые». Ниже они классифицированы по системной роли.

21.1 Foundation primitives

Addon Dependencies Архитектурная роль
angee.resources angee.base tiered data-as-code, xref ledger, adoption, grants
angee.iam resources, graphql, Django auth/sessions, axes user/identity, login, roles, permission hub, admin bootstrap
angee.money iam, resources, graphql currencies, dated exchange rates, explicit precision/rounding
angee.scheduling graphql recurrence/scheduling fields и календарная API-семантика
angee.sequence iam, graphql concurrency-safe configurable counters/numbering
angee.tags iam, resources, graphql tags и canonical generic record assignment
angee.uom iam, resources, graphql unit categories, conversions и measurement vocabulary

21.2 Shared data/collaboration substrate

Addon Dependencies Архитектурная роль
angee.storage iam, resources, graphql, contenttypes drives/folders/files, storage backends, upload/finalize, attachments, previews
angee.parties iam, integrate, storage people/organizations, handles, addresses, directories, relationships; MTI reference domain
angee.messaging iam, parties, integrate, storage, contenttypes, postgres threads/messages/fragments/parts/reactions, activities/followers/notifications, external channels
angee.posts messaging, integrate, parties, graphql public/feed projection over messaging primitives
angee.spaces iam, parties, messaging group membership/collaboration boundary; permission extension к threads
angee.knowledge iam vault/page/Markdown knowledge, outline/section edits, links/history
angee.nexus parties, messaging relationship cadence/gravity/rollup projection

21.3 Integration substrate и adapters

Addon Dependencies Архитектурная роль
angee.integrate iam, resources, tasks credentials, OAuth clients/accounts, backend registries, sync lifecycle, scheduler, events, safe outbound HTTP
angee.integrate_github integrate GitHub implementation adapter
angee.iam_integrate_oidc iam, integrate, parties OIDC identity provider bridge
angee.parties_integrate_carddav parties, integrate CardDAV/vCard round-trip
angee.messaging_integrate_imap messaging, integrate, parties incremental IMAP ingestion and email decomposition
angee.messaging_integrate_telegram messaging, integrate, parties Telegram user-session bridge
angee.messaging_integrate_whatsapp messaging, integrate, parties WhatsApp multi-device bridge
angee.knowledge_graph_pgvector knowledge, mcp PostgreSQL vector/knowledge graph extension

21.4 Agent/platform/workflow plane

Addon Dependencies Архитектурная роль
angee.mcp iam authenticated FastMCP server, addon tool registration, actor-scoped GraphQL tools
angee.operator iam, integrate bridge к внешнему Go operator daemon; отдельная external SDL/codegen surface
angee.platform iam, resources platform control/catalog/schema/addon explorer
angee.platform_integrate_operator platform, operator platform actions backed by operator control plane
angee.platform_integrate_vcs platform, integrate source/VCS catalog integration
angee.agents integrate, operator, mcp providers/models/agents/skills/tool servers, inference/runtime provisioning, ACP session surface
angee.agents_integrate_anthropic agents, integrate Anthropic inference backend registry contribution
angee.agents_integrate_openai agents, integrate OpenAI inference backend registry contribution
angee.workflows iam, tasks workflow/step/edge/trigger/run/decision graph и Celery execution
angee.workflows_agents workflows, agents agent-backed workflow step implementation

21.5 Что делает integrate системным addon’ом

integrate — не просто каталог connectors. Он владеет общими seams:

  • зашифрованные credentials/app keys;
  • OAuth2 protocol и refresh/revoke;
  • settings-backed backend/capability registries;
  • bridge lifecycle/state transitions;
  • task scheduling, retry, sync locks;
  • typed integration events;
  • local live-session store conventions;
  • outbound HTTP с SSRF protection.

PinnedTransport разрешает hostname один раз, валидирует IP, а затем dial’ит именно проверенный address, сохраняя TLS hostname semantics. Это закрывает DNS rebinding окно между validation и connect. Network resource source присоединяется к resources через registry, а не import inversion.

21.6 Messaging как shared interaction kernel

Messaging содержит больше, чем чат:

  • conversation/thread identity;
  • content-addressed fragments и message parts;
  • reactions;
  • attachments;
  • participants/followers;
  • record-scoped chatter;
  • activities и notifications;
  • channel bridges;
  • ingestion/deduplication;
  • PostgreSQL full-text support.

Поэтому posts, spaces, nexus и external messaging connectors строятся поверх него, а не создают параллельные event/message модели.

21.7 Knowledge editing

Markdown knowledge не переписывает document через render/parse roundtrip. markdown-it-py даёт source line spans; model API может получить outline, найти уникальную section и splice исходный text. Это сохраняет source fidelity.

21.8 Agents и MCP

Agent — first-class permissioned principal, а не API key, спрятанный в feature flag. Addon моделирует inference provider/model, agent configuration, skills, MCP servers/tools и runtime binding. Vendor addons добавляют implementations через registry.

FastMCP mounted как ASGI application. Bearer token verifier превращает credential в REBAC actor; middleware оборачивает каждый tool call actor context. GraphQL MCP tool выполняет ту же GraphQL operation под тем же actor scope, поэтому агент не получает отдельный privileged data path.

21.9 Workflows

Workflow engine разделяет:

  • definition graph: workflow, steps, edges, triggers;
  • execution graph: runs, step runs, decisions;
  • transition/gate semantics;
  • background scheduling/execution;
  • implementation seam для типа шага.

workflows_agents добавляет agent step как внешний implementation, сохраняя базовый engine независимым от конкретного agent runtime.


22. Host, example, templates и operator boundary

22.1 Host

Host — конкретный Django/React project, который выбирает root addon’ы и deployment configuration. Framework не предполагает единственного продукта. examples/notes-angee показывает минимальную композицию реального domain addon + shared base addons.

22.2 Operator

Внешний Go operator отвечает за:

  • Git Sources;
  • Workspaces;
  • dev/prod Stacks;
  • containers/processes;
  • logs/lifecycle;
  • rendering templates.

angee-django отвечает за содержимое runnable Host. angee.operator addon является клиентским bridge, но не переносит control-plane ответственность внутрь Django.

22.3 Templates

Copier templates закрепляют golden paths:

  • новый project;
  • addon;
  • addon web package;
  • stack/workspace;
  • service.

Шаблоны — часть архитектуры для agent-native development: они уменьшают число допустимых форм одного и того же решения. Система полагается не только на документацию, но и на scaffold, manifest conventions и policy tests.

22.4 Platform installer

Addon installation через platform/operator path изменяет project settings.yaml, сохраняя комментарии и формат через ruamel.yaml, затем требует rebuild/reprovision. Это сознательный write path конфигурации. Runtime не «подключает» addon в память текущего процесса.


23. Сквозные жизненные циклы

23.1 Установка addon’а

source/package становится доступен
→ root addon добавляется в project settings
→ AppGraph рассчитывает dependency closure
→ autoconfig складывает settings
→ build композирует models/permissions/web manifest
→ runtime migrations materialize
→ Django migrations применяются
→ permission schema синхронизируется
→ resources загружаются
→ SDL/metadata/codegen обновляются
→ новый Host bundle запускается

23.2 Чтение списка

React ResourceList
→ @angee/metadata находит capabilities/fields/roots
→ @angee/refine строит Hasura query + auth/CSRF context
→ GraphQL resource resolver
→ AngeeManager/RebacQuerySet row scope + field redaction
→ Django ORM
→ typed rows
→ widgets/views по metadata

23.3 Создание записи

FormView
→ generated typed create/save document
→ GraphQL input decoding
→ relation IDs разрешаются в scoped querysets
→ check_new(would-be relationships)
→ narrow system-context insert
→ full validation / transaction
→ on_commit change publication
→ metadata-directed cache invalidation

23.4 Atomic parent + lines save

full desired form state
→ parent write scope
→ select_for_update parent
→ validate line ownership/IDs
→ calculate create/update/delete diff
→ apply all changes in one transaction
→ return fully re-seeded record

23.5 Realtime

committed save/delete
→ ChangePayload
→ transaction.on_commit
→ Channels group
→ subscription actor context
→ ChangeReadGate
→ authorized/redacted payload
→ Refine live provider
→ targeted query invalidation/refetch

23.6 Integration ingestion

scheduler / live connector
→ task lock
→ backend selected via ImplClassField registry
→ remote protocol client
→ normalized domain ingestion inside sync context
→ xrefs/cursors/deduplication
→ normal model validation and permissions-owned internal path
→ events/progress/retry state

23.7 Agent tool call

MCP bearer token
→ token verifier
→ identity → REBAC actor
→ per-call actor context
→ addon MCP tool or GraphQLTool
→ ordinary scoped GraphQL/ORM operation
→ audited result

24. Контракты расширения

Нужно расширить Правильная seam Результат
добавить capability новый addon + addon.toml dependency-composed vertical slice
добавить поля/поведение к модели abstract AngeeModel(extends=...) same-row concrete class composition
создать реальный subtype/table runtime=True + extends Django MTI child
добавить settings defaults autoconfig.py ordered settings fold
добавить реализацию backend’а settings registry + ImplClassField selectable implementation key
добавить API named SchemaParts contribution merged Strawberry schema
открыть модель generic UI hasura_model_resource CRUD/analytics metadata + API
добавить domain command typed GraphQL action generated action document + invalidation
добавить permission vocabulary own permissions.zed independently owned schema
расширить чужую permission type permissions.extends.zed additive checked merge
поставить системные записи resource manifest + xrefs idempotent tiered load
поставить базовые grants resource kind=grants idempotent REBAC tuples
добавить resource source source registry materialization without core import
добавить фоновую работу Celery task + lock seam queue/retry/concurrency
добавить HTTP/WebSocket/MCP surface addon contribution/mount composed stable ASGI router
добавить UI route/menu/form/widget defineAddon manifest fail-fast composed React runtime
изменить rendering типа поля widget registry metadata-driven renderer/editor
добавить custom GraphQL document convention document file schema-routed typed codegen
мигрировать уже установленную композицию addon runtime migration declaration generated project migration with provenance

Анти-паттерны по архитектуре репозитория:

  • monkeypatch чужого класса;
  • runtime global register() как основной composition mechanism;
  • ручное редактирование generated runtime;
  • frontend permission без server gate;
  • ручное дублирование GraphQL result types;
  • addon import, не объявленный в depends_on/package.json;
  • network concern внутри resource core;
  • domain business logic в composer;
  • serving entrypoint, импортирующий build-time compose;
  • скрытый superuser bypass.

25. Границы безопасности

25.1 Сильные границы

  1. Row authorization по умолчанию. Доступ начинается со scoped manager/queryset.
  2. Field authorization. Закрытое поле redacted сервером.
  3. Create preflight. Проверяется будущая relation graph до insert.
  4. Narrow elevation. System context используется после gate и на минимальном участке.
  5. Opaque IDs. PK не становится API identity.
  6. No implicit admin bypass. Привилегия выражена schema/tuples.
  7. Subscription re-check. Long-lived event stream не доверяет доступу на момент подписки навсегда.
  8. Deletion privacy. Preview не раскрывает невидимые dependent rows.
  9. CSRF distinction. Session mutation и bearer request разделены.
  10. Encrypted secrets. Sensitive implementation fields используют type-level encrypted field.
  11. SSRF-pinned HTTP. Validation и TCP target не расходятся из-за повторного DNS lookup.
  12. Manifest path safety. Local resources не могут выйти из addon root.
  13. Permission merge fail-fast. Additive extension не может тихо перезаписать чужое правило.

25.2 Trust boundaries

Addon code — доверенный application code. Build-time compiler обеспечивает конфликты и направление композиции, но не sandbox’ит Python package. Установка произвольного addon’а эквивалентна установке trusted server dependency.

Generated frontend metadata — trusted artifact build’а, но @angee/metadata всё равно валидирует shape. Client никогда не считается security authority.

External integrations выполняются в workers/queues и должны входить в domain через нормализованные ingestion APIs. Vendor SDK не должен диктовать model schema или permission bypass.


26. Транзакции, консистентность и детерминированность

26.1 Build consistency

  • sorted dependency traversal;
  • stable content rendering;
  • atomic writes;
  • generated sentinel;
  • --check drift mode;
  • migration source digests;
  • collision detection.

26.2 Data consistency

  • Django transactions для composed writes;
  • parent row lock для editable lines;
  • optimistic state-transition source check;
  • transaction.on_commit для realtime;
  • natural-key/idempotent resources;
  • idempotent REBAC grants;
  • advisory task locks;
  • integration cursors/deduplication в owning addon.

26.3 Cache consistency

Frontend operation hooks используют metadata-directed invalidation. Live event — сигнал invalidate/refetch, а не попытка локально воспроизвести все серверные изменения. Это уменьшает риск расхождения сложных aggregates/groups/permissions.

26.4 Fresh process как часть consistency model

Generated Python graph нельзя hot-swap безопасно после Django import. Angee не скрывает это; build/provision boundary явно требует новый процесс. Такая остановка менее «магична», зато final registry остаётся обычным Django registry.


27. Performance architecture

В исходниках видны следующие осознанные меры:

  • resource ledgers primed одним query на dataset;
  • content hash позволяет skip unchanged resources;
  • QuerySet scoping выполняется в БД;
  • aggregate path избегает instance field-redaction overhead;
  • GraphQL Django optimizer уменьшает N+1;
  • deletion scope checks chunked;
  • identity label helpers memoize на request;
  • generated artifacts кэшируются по фактическому content;
  • React Query является единым cache owner;
  • long lists используют TanStack Virtual;
  • Calendar/preview-heavy code загружается лениво;
  • PostgreSQL pattern indexes и full-text используются там, где capability backend-specific;
  • worker-only vendor dependencies исключаются из web/console import closure архитектурным тестом.

SQLite — development/test floor, не полная замена PostgreSQL semantics. Vector search, Postgres full-text и cross-process advisory locks заведомо backend-specific; код либо предоставляет честный fallback, либо явно проверяет capability.


28. Тестовая архитектура

28.1 Backend

Backend suite покрывает:

  • project discovery/settings isolation;
  • addon manifest inference;
  • dependency graph order/cycles/collisions;
  • runtime class emission и drift;
  • source extension/MTI/MRO edge cases;
  • runtime migrations/provenance;
  • permission extension merge;
  • public IDs/canonical refs;
  • transitions/concurrency;
  • REBAC read/create/write/delete behavior;
  • resource tiers/xrefs/adoption/grants;
  • GraphQL composition/CRUD/filters/aggregates/groups/lines/actions;
  • deletion preview и revisions;
  • change publication/subscriptions;
  • IAM/auth/roles;
  • tasks/locks;
  • storage, parties, messaging, knowledge, agents, workflows и connectors;
  • templates и example project.

28.2 Layering tests

tests/test_layering.py автоматически доказывает:

  • angee.base не импортирует sibling subsystems или addon’ы;
  • нет общего angee.apps.AppConfig base, затягивающего лишнюю иерархию;
  • resources и GraphQL не импортируют compose;
  • serving URLs/ASGI не импортируют compose;
  • permission renderer не возвращён внутрь composer;
  • live console import closure не тянет worker-only WhatsApp/Telegram/Pillow dependencies.

28.3 Frontend guardrails

architecture-guardrails.test.ts проверяет:

  • разрешённое направление imports между четырьмя framework packages;
  • каждый addon импортирует только declared package dependencies;
  • addon web graph ацикличен;
  • удалённые старые shell packages не возвращаются;
  • critical shared owners реально переиспользуются;
  • row identity helpers берутся из metadata owner;
  • authored hooks остаются в refine owner.

28.4 Остальные уровни

  • Vitest: pure modules, hooks, components, routed app flows.
  • Storybook: primitives, generic views и addon visual surfaces.
  • Playwright: browser flows с isolated workspace, role storageState, GraphQL fixture и page objects.
  • CI: ruff, mypy, pytest, frontend checks, codegen/build drift, private path policy, secret scanning.

Тестовая пирамида здесь одновременно является архитектурным линтером. Это особенно важно для agent-written code: многие деградации — не функциональный bug сегодня, а появление второго владельца concern. Guardrail ловит именно это.


29. Сильные стороны архитектуры

29.1 Композиция приводит к обычному Django runtime

После build система не эмулирует ORM. Django видит реальные concrete classes, relations и migrations. Это сохраняет совместимость с mature ecosystem.

29.2 Один backend/frontend IR

Metadata соединяет model capabilities, GraphQL names, typed documents и generic UI. Большой класс drift bugs исключается конструктивно.

29.3 Permissions проходят через все surfaces

ORM, GraphQL, subscriptions и MCP используют одного actor/REBAC owner. Агент не получает special backdoor.

29.4 Fail-fast collision policy

Model labels, fields, routes, menus, widgets, schema roots, permission extensions и package dependencies проверяются. «Последний импорт победил» допускается только там, где override объявлен частью контракта.

29.5 Ясная ownership model

Каждый concern делегирован библиотеке или конкретному Angee package/addon. Architecture tests проверяют, что владельцы не расползаются.

29.6 Base addons — реальные reference implementations

Patterns показаны не на игрушечном blog addon, а на IAM, files, OAuth, messaging, realtime, agent runtime и workflows. Для coding agent это ценный набор сложных примеров.

29.7 Build artifacts проверяемы

Детерминированный output, drift check, migration digest и fresh-process boundary делают генерацию наблюдаемой и CI-friendly.


30. Ограничения и архитектурная цена

30.1 Early alpha

README прямо предупреждает об active refactor и отсутствии production guarantee. Архитектурная цель амбициозна, но compatibility surface ещё движется.

30.2 Build/provision сложнее обычного Django

Появляются дополнительные состояния:

  • source declarations;
  • generated runtime;
  • generated migrations;
  • DB state;
  • permission schema state;
  • SDL/metadata/client artifacts.

Инструменты стараются синхронизировать их, но debugging требует понимать compiler pipeline.

30.3 Static composition требует rebuild

Нельзя безопасно включить addon в текущий production process одной строкой runtime registry. Нужны rebuild, migrations, resources, schema/codegen и restart. Это осознанный обмен динамичности на аудитируемость.

30.4 Same-row model extension предъявляет строгие требования

Field ownership, labels, MRO, managers и migration intent должны быть однозначны. Произвольное patching проще локально, но несовместимо с детерминированным compiler.

30.5 Framework уже не «маленький»

Python composition core относительно компактен, но generic GraphQL и UI binding крупные. Особенно тяжёлые areas — forms/lists/groups, metadata dialect и integration/message ecosystems. Заявление «thin framework» верно в смысле делегирования concerns, но не в смысле малого объёма всей distribution.

30.6 PostgreSQL является фактическим production target

SQLite полезен для local floor/tests, но advisory locks, vector search, Postgres full text и часть analytical behavior не могут иметь полностью идентичную семантику.

30.7 Trusted addon model

Build-time checking не является sandbox. Внешний addon может выполнить произвольный Python/JS код с правами приложения. Supply-chain review остаётся необходимым.

30.8 Generic UI не отменяет custom UX

Metadata отлично покрывает CRUD, analytics и standard views, но сложный domain workflow всё равно требует authored schema actions и addon React surface. Это не fully automatic UI generator.

30.9 Большие центральные frontend components

FormView, ResourceList и list body концентрируют много semantics. Они хорошо тестируются, но остаются hotspot’ами, где изменение generic behavior влияет на весь addon catalogue.


31. Как мыслить об Angee: пять одновременных разрезов

Разрез A: compiler

declarations → dependency graph → composition → generated program

В этом разрезе addon.toml — manifest, source model — AST-подобный input, DataResourceMetadata — IR, runtime files — target program.

Разрез B: clean architecture

base/domain → data/API adapters → rendered UI → composition root

Dependencies идут наружу через narrow seams; core не знает конкретных connectors.

Разрез C: vertical addons

model + permissions + API + resources + tasks + web = capability

Каждый addon несёт вертикальный срез, но использует общие horizontal framework services.

Разрез D: actor-safe data plane

human/service/agent → one identity → one REBAC graph → ORM/GraphQL/MCP

Agent-native здесь означает прежде всего identity/permission parity, а не встроенную кнопку LLM.

Разрез E: generated product shell

backend metadata → typed client → generic Refine/UI views + authored addon UX

Продукт состоит из reusable generic surfaces и точечных domain contributions.


32. Важнейшие инварианты системы

  1. Всё устанавливаемое — addon; addon объявляет manifest.
  2. Проект выбирает roots, Composer рассчитывает dependency closure.
  3. Порядок композиции детерминирован.
  4. Framework владеет seams, зрелые libraries — concerns.
  5. Source models абстрактны; tables принадлежат generated runtime.
  6. Same-row extension не допускает field collision.
  7. Runtime output не редактируется вручную.
  8. Изменение composed Python graph требует fresh interpreter.
  9. Runtime migration сохраняет provenance и digest.
  10. Serving layer не импортирует build-time compose.
  11. REBAC row scope — default, отсутствие actor’а fail-closed.
  12. Create permission проверяется до insert.
  13. Elevation узкая и следует после authorization.
  14. Superuser не обходит schema неявно.
  15. Public API identity не равна database PK.
  16. Subscription повторно проверяет actor visibility.
  17. Event публикуется только после transaction commit.
  18. Resource load idempotent и xref-based.
  19. Remote resource source принадлежит integration layer.
  20. Backend metadata, GraphQL dialect и frontend adapters меняются согласованно.
  21. UI capabilities выводятся из metadata, но не считаются security rules.
  22. Web addon composition статична и collision-checked.
  23. Frontend package imports направлены снизу вверх.
  24. Addon-to-addon dependency должна быть объявлена и в Python, и в web manifest.
  25. Новый concern должен иметь одного очевидного владельца.

33. Навигация по исходникам

Bootstrap и composition

Файл Зачем читать
angee/project.py project discovery
angee/compose/project.py settings bootstrap и ProjectContract
angee/addons.py manifest contract/discovery
angee/compose/appgraph.py dependency graph
angee/compose/autoconfig.py settings fold
angee/compose/composer.py orchestration owner
angee/compose/runtime.py model/runtime compiler
angee/compose/migrations.py addon-owned runtime migrations
angee/compose/permissions.py .extends.zed merge

Model и security layer

Файл Зачем читать
angee/base/models.py AngeeModel, managers, scoped helpers, public IDs
angee/base/mixins.py reusable model contracts
angee/base/fields.py Sqid/State/Encrypted fields
angee/base/impl.py implementation registries
angee/base/transitions.py guarded state machine
angee/base/refs.py canonical generic/MTI refs
addons/angee/iam/models.py identity model
addons/angee/iam/roles.py schema/role introspection

Resources и API

Файл Зачем читать
addons/angee/resources/entries.py manifest entries и dependency graph
addons/angee/resources/loader.py xref/adoption/hash import semantics
addons/angee/resources/managers.py validate/load/diff orchestration
addons/angee/resources/grants.py declarative REBAC tuples
angee/graphql/schema.py named schema composition
angee/graphql/data/hasura.py generic Hasura resource
angee/graphql/data/metadata.py backend/frontend IR
angee/graphql/access.py, angee/graphql/writes.py GraphQL read/write authorization boundary
angee/graphql/deletion.py guarded deletion
angee/graphql/events.py, publishing.py, subscriptions.py realtime publication/subscription

Serving и tasks

Файл Зачем читать
angee/urls.py URL contribution fold
angee/asgi.py protocol router/mounts/lifespan
angee/tasks/celery.py, enqueue.py Celery integration и постановка задач
angee/tasks/locks.py local/PostgreSQL locks

Frontend

Файл Зачем читать
angee/web/app/bin/angee-web-codegen.mjs SDL/metadata→typed client→app.ts pipeline
angee/web/app/src/define-addon.ts web addon contract
angee/web/app/src/create-app.tsx React composition root
angee/web/app/src/route-tree.tsx route composition
angee/web/metadata/src/artifact.ts artifact validation
angee/web/metadata/src/projection.ts metadata→Refine projection
angee/web/refine/src/provider.ts Hasura data provider
angee/web/refine/src/dialect/hooks.tsx authored aggregate/group/action hooks
angee/web/ui/src/runtime/contracts.ts UI extension contracts
angee/web/ui/src/views/* generic data views
angee/web/ui/src/widgets/* field widget system

Architecture enforcement

Файл Зачем читать
AGENTS.md constitution и ownership rules
docs/stack.md external library owners
tests/test_layering.py backend dependency direction
angee/web/app/src/architecture-guardrails.test.ts frontend dependency direction
pyproject.toml Python distribution/toolchain
pnpm-workspace.yaml frontend workspace boundary

34. Итоговая формула

Angee Django состоит не из одного «ядра» в традиционном смысле, а из четырёх взаимосвязанных машин:

  1. Composition machine — manifests, dependency graph, settings fold, source model compiler, permission merge и generated runtime.
  2. Permissioned data machine — Django ORM, REBAC managers, resources, migrations, validation, transactions, audit/history.
  3. Typed interface machine — named Strawberry schemas, Hasura dialect, metadata IR, actions, analytics, realtime и MCP.
  4. Rendered application machine — codegen, Refine providers, metadata projection, generic UI и composed addon React manifests.

Operator снаружи создаёт и запускает workspace/stack; Host выбирает root addons; base addon distribution предоставляет проверенные системные capabilities. Внутри всё держится на одном решении: декларации композируются заранее в один обычный runtime, а не патчат его на ходу.

Если свести архитектуру к одной строке:

Angee — это детерминированный compiler вертикальных Django/React addon’ов в единое permissioned, metadata-driven приложение для людей, сервисов и агентов.

Именно generated IR между Django и React, встроенный REBAC и строгие ownership/extension seams отличают его от обычного «Django project с reusable apps».

Часть II. Архитектура Angee Operator (angee-go): полный разбор по исходному коду

Продолжение «Архитектура Angee Django: полный разбор по исходному коду». Первая часть описывала runtime-половину системы — Django/React-компилятор приложения. Эта часть описывает вторую половину: Go-control plane, который вообще создаёт, наполняет и запускает окружение, внутри которого живёт Host. В терминах таблицы слоёв из части I это «слой 0», который там был обозначен одной строкой — «внешний angee-operator».

Документ — самостоятельный source audit репозитория angee-operator (он же angee-go). Архитектура восстановлена снизу вверх: от схемы angee.yaml и capability-safe файловых примитивов до компилятора runtime-файлов, git-слоя, транзакционного рендеринга шаблонов и трёх изоморфных поверхностей control plane.

0. Паспорт анализа

Параметр Значение
Репозиторий angee-operator (github.com/ang-ee/angee-operator)
Локальный путь /Users/max/conductor/workspaces/angee-operator/spokane
Commit e74a6145416358d53e5bf491b7a127744a389e15 (fix(service): open a local source located at the render target root)
Дата commit 2026-07-16
Дата анализа 2026-08-05
Последний тег в CHANGELOG.md v0.8.4 (2026-07-16)
Язык Go 1.25.0 (CI фиксирует toolchain 1.25.12)
Артефакты сборки dist/angee (CLI), dist/angee-operator (демон)
Статус рабочий прототип / active development

Что именно просмотрено

Инвентаризированы все 229 отслеживаемых Git-файлов, из них 170 — Go. Прочитаны по слоям: схема манифеста, компилятор стека, оба runtime-backend'а, edge-backend, слой шаблонов и stateful-реконсиляции, capability-safe файловый слой, git-клиент, оркестрация workspace'ов, обе поверхности оператора (REST и GraphQL), движок фильтрации коллекций, подписки, токены, CLI и его remote-клиент. Опубликованная документация (docs/) и внутренние заметки (.agents/) использованы как заявление о намерениях, но проверялись по коду.

Количественный профиль:

Область Файлов .go Строк (с тестами)
internal/operator/gql/ (gqlgen) 12 ≈ 32 188
internal/service/ 50 ≈ 14 328
internal/operator/ 26 ≈ 5 479
internal/copierx/ 4 ≈ 4 584
internal/cli/ 11 ≈ 3 965
internal/manifest/ 4 ≈ 1 258
internal/platformclient/ 2 ≈ 852
internal/query/ + internal/queryfields/ 10 ≈ 1 143
internal/runtime/** 14 ≈ 2 056
internal/git/, internal/store/, internal/secrets/, internal/substitute/, прочее ≈ 40 ≈ 3 900
Всего Go 170 71 003
из них не-тестовый код 101 55 799
из них сгенерировано gqlgen (generated.go + models_gen.go) 2 29 557
рукописный не-тестовый Go ≈ 26 240
Тесты 69 файлов 15 204 строк, 427 func Test*

Существенная деталь: больше половины не-тестового Go — сгенерированный gqlgen-код. Рукописная кодовая база оператора — порядка 26 тысяч строк, при этом из них ~14 тысяч приходится на один пакет internal/service. Это не «микросервис-фреймворк», а один плотный домен с очень тонкими адаптерами вокруг.

Второе наблюдение: плотность тестов на рукописную строку выше, чем в angee-django. 129 тестов на internal/service, 72 на internal/operator, 42 на internal/copierx. И, как и в первой части, значительная доля тестов — отрицательные: они закрепляют, чего слой делать не имеет права (выйти за корень, перезаписать локальную правку, оставить частичное состояние после ошибки).


1. Короткий ответ: что такое angee-operator

angee-operator — это компилятор одного декларативного манифеста в исполняемое окружение плюс control plane над этим окружением.

На входе — ровно один файл:

version: 1
kind: stack
name: notes-angee

…и всё, что он объявляет: секреты, порты, тома, персистентные пути, источники (git/local), workspace'ы, сервисы, задания, ingress-маршруты.

На этапе компиляции оператор:

  1. разрешает корень стека (ANGEE_ROOT);
  2. строго парсит и валидирует angee.yaml (unknown fields — ошибка);
  3. резолвит секреты через выбранный backend (env-file или OpenBao);
  4. раскрывает ${...}-подстановки в env, командах, портах, монтированиях и workdir;
  5. переводит mount-URI (workspace://, source://, volume://, bind://) в реальные пути хоста;
  6. материализует источники (git-кэши, worktree, local-пути) и персистентные каталоги;
  7. эмитит docker-compose.yaml для runtime: container и process-compose.yaml для runtime: local;
  8. даёт ingress-backend'у дописать в скомпилированный compose edge-сервис и маршрутные labels.

На выходе работает обычный, хотя и сгенерированный, стек:

  • Docker Compose с проектным именем, уникальным на уровне демона;
  • process-compose как супервизор локальных процессов;
  • git-worktree'ы под workspace'ами;
  • Caddy-edge с per-service forward_auth в оператор;
  • run/secrets.env (в режиме OpenBao) как единственная точка приземления резолвленных значений.

И над этим — control plane: HTTP-демон, отдающий одну и ту же операционную поверхность через REST и GraphQL, плюс CLI, который через ту же поверхность работает либо in-process, либо по HTTP.

flowchart TB
    M["angee.yaml\nодин манифест на один ANGEE_ROOT"]
    T["Copier-шаблоны\nstack · workspace · service"]
    S["Sources\ngit-кэши · worktree · local-пути"]
    P["service.Platform\nкомпилятор + оркестратор"]
    R["Сгенерированный runtime\ndocker-compose.yaml · process-compose.yaml · run/secrets.env"]
    D["docker compose"]
    C["process-compose"]
    E["Caddy edge"]
    O["Operator HTTP\nREST · GraphQL · SSE/WS"]
    L["CLI angee"]
    H["Хост-приложение\n(angee-django и другие)"]

    T --> P
    M --> P
    S --> P
    P --> R
    R --> D
    R --> C
    R --> E
    P --> O
    P --> L
    O --> H
    D --> H
    C --> H
Loading

Главный архитектурный принцип: одна операция — одна реализация — три транспорта. Ни CLI, ни REST-хендлер, ни GraphQL-резолвер не содержат бизнес-логики; все они держат интерфейс service.API и не знают, кто за ним — локальный *Platform или HTTP-клиент к чужому оператору.

Второй принцип, менее очевидный, но пронизывающий половину кода: всякая запись на диск — транзакция с явным откатом и с проверкой идентичности пути после операции. Оператор пишет в каталоги, которые одновременно редактирует человек и агент; поэтому файловые примитивы здесь не os.WriteFile, а capability-объекты поверх os.Root.


2. Граница между angee-go и angee-django

Две части одной системы разведены по одной линии: оператор владеет тем, где и как что-то работает; Host владеет тем, что именно там работает.

Вопрос Владелец Артефакт
Какие репозитории нужны, на каких ветках angee-go sources: в angee.yaml
Где на диске лежит рабочая копия angee-go sources/<name>, workspaces/<name>/<slot>
Какие процессы/контейнеры запущены и на каких портах angee-go compose / process-compose
Какие секреты и как резолвятся angee-go secrets_backend, run/secrets.env
Как ветка разработчика превращается в живой стенд angee-go workspace-шаблон + chain
Какие Django-модели, GraphQL-схемы, REBAC-правила, React-вьюхи angee-django addon'ы и generated runtime
Как приложение собирает себя перед стартом angee-django manage.py angee build

Оператор не знает слова «Django». В его манифесте Host — это просто service с образом или командой и набором монтирований. Обратная зависимость тоже узкая: angee-django содержит addon angee.operator, который является клиентским мостом к REST/GraphQL оператора, но не переносит control-plane-ответственность внутрь Python-процесса.

Эта граница проведена намеренно и охраняется структурно: в go.mod нет ничего Python-специфичного, а в docs/guide/concepts.md она зафиксирована текстом — «Angee is designed so application frameworks plug in on top of the engine».

Практическое следствие для читателя первой части: цепочка «Host выбирает root addons → композиция → generated runtime» начинается после того, как оператор уже склонировал репозитории, разложил worktree, выделил порты, поднял Postgres и Redis и запустил контейнер с Django.


3. Полная карта логических слоёв

Логический слой Физические владельцы Что делает Что не должен делать
0 Root discovery internal/stackroot находит ANGEE_ROOT вверх по дереву не читает содержимое манифеста
1 Схема манифеста internal/manifest strict-парсинг, defaults, валидация, канонический re-save, Ensure не обращается к сети, диску вне корня, docker
2 Подстановки internal/substitute грамматика ${ns.path | filter} не знает, куда попадёт значение
3 Mount URI internal/mount workspace://, source://, volume://, bind:// → пути/env не резолвит сами ресурсы
4 Порты internal/ports пулы, аренды, проба занятости хоста не пишет манифест
5 Секреты internal/secrets env-file / OpenBao, резолв деклараций не решает, какие секреты нужны
6 Capability-FS internal/copierx (TrustedRoot, GuardedPath) rooted-открытие, verify-after-write, откат не знает про шаблоны и манифест
7 Реконсиляция рендера internal/copierx/reconcile.go fingerprint'ы, 3-way, конфликты, dry-run, apply/rollback не знает про Angee-домен
8 Шаблоны internal/copierx/copierx.go copier.yml, _angee-метаданные, типы входов не выполняет git-операции
9 Git internal/git гибрид go-git + git CLI не знает про workspace/slot
10 Файловый объектный слой internal/store, internal/files key→blob, etag/CAS, containment не парсит YAML
11 Домен internal/service стек, сервисы, задания, источники, workspace'ы, gitops, секреты, файлы не открывает HTTP-сокеты
12 Runtime-backend'ы internal/runtime{,/compose,/proccompose} запуск/остановка/логи через внешние супервизоры не знает про манифест
13 Edge internal/runtime/edge вклад Caddy в скомпилированный compose не хранит состояние
14 Контракт control plane internal/service/api.go интерфейс service.API не содержит реализации
15 Локальная реализация internal/service.Platform in-process
16 Удалённая реализация internal/platformclient.RemoteClient HTTP-клиент не дублирует бизнес-логику
17 Движок коллекций internal/query, internal/queryfields filter/sort/page поверх слайсов не знает про транспорт
18 REST internal/operator/*.go маршруты, auth, стримы, WS-сокеты логов не реализует домен
19 GraphQL internal/operator/gql Hasura-диалект, агрегаты, группировки, подписки не реализует домен
20 CLI internal/cli Cobra-команды, выбор транспорта, doctor не ходит в docker напрямую
21 Верификация тесты, surface_matrix_test, check-generated, check-schema, CI доказывает контракты не подменяет реализацию

Направление зависимостей одностороннее и структурно закреплённое. api/ — stdlib-only DTO-пакет; он не импортирует internal/query, чтобы остаться чистым wire-контрактом. internal/queryfields — единственный пакет, которому разрешено импортировать и api, и internal/query; это устраняет цикл, который иначе возник бы между DTO и движком фильтрации. internal/service не импортирует internal/operator. internal/operator и internal/cli не импортируют друг у друга ничего, кроме команды запуска демона.


4. Физическая структура репозитория

api/                       # общие DTO запросов/ответов (stdlib-only)
cmd/
  angee/                   # CLI-бинарь
  operator/                # standalone-демон
  schema/                  # генератор JSON Schema манифеста
  gqlschema/               # экспорт GraphQL SDL
internal/
  cli/                     # Cobra-команды + выбор локального/удалённого транспорта
  copierx/                 # Copier-интеграция, _angee-метаданные, capability-FS, реконсиляция
  files/                   # объектный слой над store (etag/CAS, UTF-8, лимит размера)
  fslock/                  # advisory-блокировка корня стека
  git/                     # гибридный git-клиент
  manifest/                # схема angee.yaml, валидация, Ensure
  mount/                   # разбор mount-URI и резолв workdir
  operator/                # HTTP-сервер, REST, auth, токены, edge-verify, лог-сокеты
    gql/                   # gqlgen-схема, резолверы, Hasura-биндинг, EventHub
  platformclient/          # RemoteClient — реализация service.API поверх HTTP
  ports/                   # пулы и аренды портов
  query/, queryfields/     # движок фильтрации коллекций и per-entity FieldMap'ы
  runtime/                 # Backend-интерфейс + stream-хелперы
    compose/               # docker compose
    proccompose/           # process-compose
    edge/                  # Caddy (caddy-docker-proxy)
  secrets/                 # env-file, OpenBao, резолв деклараций
  service/                 # домен: Platform + API-контракт
  stackroot/               # разрешение ANGEE_ROOT
  store/                   # реестр backend'ов key→blob (localfs, env-file, openbao)
  substitute/              # ${...}-резолвер и фильтры
templates/agent-runtime/   # поставляемый service/workspace-шаблон (ACP-контракт)
docs/                      # VitePress-исходник docs.angee.ai
.agents/                   # планы, заметки, sub-agent'ы (не публикуется)

Два бинаря, одна сборка

cmd/angee и cmd/operator — тонкие обёртки. cmd/operator/main.go — 28 строк; cmd/angee/main.go — 15. Оператор дополнительно доступен как подкоманда CLI (angee operator), то есть один и тот же operator.Execute вызывается из двух точек входа. Версия внедряется через -ldflags -X в две переменные (cli.Version, operator.Version).

Dockerfile.operator собирает статический бинарь и кладёт его в alpine с docker-cli, docker-cli-compose и git. Это архитектурно значимо: оператор не разговаривает с Docker по API — он шеллится в CLI, и поэтому его образ обязан содержать клиент и сокет-монтирование.

Зависимости

Прямых зависимостей четырнадцать, и подбор консервативный:

Зависимость Роль
fyltr/copier-go рендер Copier-шаблонов (Jinja через pongo2)
spf13/cobra CLI
99designs/gqlgen + vektah/gqlparser/v2 GraphQL-сервер по SDL
go-git/go-git/v5 read-only git-запросы без fork/exec
bluekeyes/go-gitdiff разбор unified diff в структурированные хунки
go-playground/validator/v10 декларативная валидация схемы манифеста
golang-jwt/jwt/v5 HS256-токены оператора
gorilla/websocket graphql-transport-ws и per-service лог-сокеты
invopop/jsonschema генерация JSON Schema манифеста
gosimple/slug фильтр slug в подстановках
gopkg.in/yaml.v3 YAML
golang.org/x/sync errgroup в foreground-режиме

Явно отсутствуют: HTTP-фреймворк (используется net/http + ServeMux с method-patterns Go 1.22+), ORM, DI-контейнер, логгер. Логирование — fmt.Fprintf(os.Stderr, ...). Это осознанный минимализм: у оператора нет состояния, кроме файлов в корне стека.


5. Доменная модель: примитивы и их отношения

Домен маленький и жёстко очерченный. Всё, чем оператор оперирует, объявлено в одном манифесте.

Примитив YAML-ключ Что это Кто материализует
Stack весь файл один ANGEE_ROOT с angee.yaml + сгенерированные runtime-файлы stack init из Copier-шаблона
Service services.<name> долгоживущая нагрузка; runtime: container → compose, runtime: local → process-compose Compile
Job jobs.<name> явно вызываемая команда с той же семантикой env/mount/workdir JobRun (local — напрямую через exec)
Source sources.<name> материал: kind: git (кэш + worktree) или kind: local (path-mount) materializeSource
Workspace workspaces.<name> отрендеренное Copier-дерево под workspaces/<name> WorkspaceCreate
Workspace source slot workspaces.<n>.sources.<slot> одна git-материализация внутри workspace'а со своей веткой/ref/subpath materializeWorkspaceSource
Secret secrets.<name> декларация: generated, required, import secrets.ResolveDeclarations
Port ports.<name> именованный фиксированный порт с опциональным export_env подстановка ${ports.x}
Port pool operator.port_pool.<name> диапазон для автоаллокации ports.Pool + port_leases
Volume volumes.<name> compose-том или локальный каталог Compile
Persist path persist.<name> каталог, переживающий пересоздание workspace'а materializePersistPaths
Route services.<n>.route публичный маршрут через edge edge.CaddyBackend.Contribute
Template файловая система Copier-шаблон с _angee.kind resolveTemplate

Три отношения задают всю топологию:

  1. Service/Job → Source|Workspace|Volume через mount-URI. Это единственный способ, которым нагрузка получает код и данные.
  2. Workspace → Source через слоты. Слот — это worktree исходного git-кэша на ветке workspace'а.
  3. Workspace → Stack через chain. Workspace-шаблон может отрендерить внутренний stack-шаблон как файлы; запускается этот стек отдельной командой.

Workspace — чистый файловый примитив

Это самое нетривиальное решение в домене и его стоит проговорить отдельно, потому что интуиция подсказывает обратное.

workspaceCreate никогда не запускает сервисы. Он рендерит дерево, материализует источники, выделяет порты, создаёт persist-каталоги и записывает запись в манифест родителя. Если шаблон зачейнил внутренний стек — этот стек лежит файлами и ждёт явного:

angee stack up --root workspaces/<name>/.angee
# или второй оператор на тот же корень:
angee operator --root workspaces/<name>/.angee --port 9100

Что это покупает: workspace становится данными, а не процессом. Внешний сервис может смонтировать workspace://foo и работать с файлами, пока внутри ничего не «поднято». Жизненный цикл workspace'а (создать/обновить/удалить) полностью отделён от жизненного цикла runtime'а (up/down/logs). Один и тот же stack up работает и над production-корнем, и над внутренним стеком workspace'а — это и есть механизм «promote to production» без отдельной CI-системы.

Историческое свидетельство того, что решение принималось не сразу: в схеме манифеста живёт поле-призрак WorkspaceResolved.LegacyLifecycle с YAML-тегом lifecycle и json:"-". Оно существует ровно для того, чтобы строгий декодер не отверг манифесты, записанные до рефакторинга «workspace как чистый файловый примитив», и молча вычищается при первом же re-save.


6. angee.yaml: схема как единственный источник истины

internal/manifest/manifest.go — 573 строки, и это самый «плотный» файл репозитория по смыслу на строку.

Строгий парсинг

dec := yaml.NewDecoder(f)
dec.KnownFields(true)

Неизвестный ключ — ошибка загрузки, а не игнор. Это принципиально: манифест одновременно редактируется человеком, шаблоном и оператором, и молчаливое проглатывание опечатки означало бы тихую потерю конфигурации.

Три уровня валидации

  1. Структурная, через struct-теги validate: (go-playground/validator): version только 1, kind только stack, runtime только container|local, route.port в [1,65535], secrets_backend.type в {env-file, openbao}, ingress.type в {none, caddy}.
  2. ValidateExtended — межполевые инварианты, которые тип выразить не может:
    • route требует runtime: container (локальный процесс не имеет контейнерного адреса в edge-сети);
    • runtime: container требует image или build; runtime: local запрещает image и требует command;
    • route.host и route.path взаимоисключающи;
    • поле, которое активный режим маршрутизации игнорирует, — ошибка, а не no-op: route.path при routing: host отвергается, и наоборот;
    • ingress.port осмыслен только при type: caddy + tls: off и отвергается в остальных случаях;
    • имя сервиса edge зарезервировано;
    • имена и хосты маршрутизируемых сервисов проверяются регулярками, а domain/image/network/verify/route.path — на caddy-метасимволы (\t\r\n{}#"``), потому что эти значения попадают в Docker labels, которыеcaddy-docker-proxy` склеивает в Caddyfile. Это защита от инъекции директив.
  3. Defaults — заполнение нулевых значений (version: 1, kind: stack, secrets_backend.type: env-file, ingress.type: none) и инициализация всех map'ов.

Дефолты при чтении, а не при записи

Тонкая, но важная деталь: Ingress.RoutingMode(), Ingress.TLSMode(), Ingress.HostPort()методы, а не поля, заполняемые в Defaults(). Комментарий объясняет почему:

Defaulting happens at read time (not in Defaults) so existing manifests re-save byte-for-byte unchanged.

Это инвариант всего репозитория: любая операция, не менявшая семантику, обязана перезаписать angee.yaml побайтово одинаково. Иначе каждая команда порождала бы diff в git и шумела бы в PR.

Marshal как чистая функция

func Marshal(stack *Stack) ([]byte, error) {
    stack.Defaults()
    if err := stack.Validate(); err != nil { return nil, err }
    return yaml.Marshal(stack)
}

SaveFile — это Marshal + WriteFile. Разделение позволяет посчитать, изменится ли файл, не записывая его — основа --dry-run и всех diff-предпросмотров.

StringList: пользовательская лояльность на границе

ports: и mounts: принимают либо скаляр, либо список, либо список маппингов. StringList.UnmarshalYAML нормализует всё в []string, а stringifyMapping переводит структурную форму обратно в каноническую URI-строку:

mounts:
  - type: bind
    source: ./data
    target: /data
    read_only: true
# → "bind://./data:/data:ro"

Толерантность — только на входе. Наружу всегда выходит одна каноническая форма.

Ensure: инварианты, объявляемые шаблоном

manifest.Ensure(stack, requirements) принимает map из dotted-path в значение и вживляет их в манифест по семантике fail-on-different:

  • пути нет → установить;
  • путь есть, значение глубоко равно → no-op;
  • путь есть, значение другое → ошибка.

Реализация работает не по типизированной структуре, а по YAML-представлению: стек маршалится в map[string]any, пути грaftятся, результат демаршалится обратно через строгий декодер и проходит Defaults+Validate. Это даёт шаблону право требовать operator.port_pool.workspace: {range: "8100-8199"}, не заставляя Go-код знать о существовании этого поля, — но любая ошибка формы всплывает немедленно, а не при следующем up.

Ensure — это точка, где Copier-шаблон объявляет свои требования к окружающему стеку. Она вызывается при workspaceCreate, serviceCreate и stack update --template.

Аренды портов живут в манифесте

port_leases: map[string][]PortLease — часть схемы. То есть состояние аллокатора персистится в том же файле, что и декларации. Отдельной базы нет, и это сознательно: оператор без состояния, весь его state — файлы под ANGEE_ROOT. Владелец аренды — строка вида workspace/<name> или service/<name>/<pool>, что делает освобождение аренд при удалении объекта тривиальным.


7. Разрешение корня и две раскладки стека

internal/stackroot.Resolve идёт вверх от стартового каталога и на каждом уровне проверяет три маркера в порядке приоритета:

  1. <dir>/angee.yaml → это корень;
  2. <dir>/.angee/angee.yaml → корень <dir>/.angee;
  3. <dir>/templates/workspaces или <dir>/.templates/workspaces → корень <dir>/.angee, даже если он ещё не создан.

Третий случай — про dev-чекауты: репозиторий с шаблонами считается местом, где стек должен быть, и angee init --dev создаст его там. Если ни один маркер не найден, возвращается исходное значение (историческое поведение CLI).

Из этого следует архитектурная развилка — две раскладки стека, которые оператор трактует одинаково, но которые означают разные вещи:

Dev-overlay Self-contained instance
ANGEE_ROOT .angee/ внутри существующего чекаута сама папка
Источники local, path-mount на окружающий код git, клонируются оператором в корень
Кто создаёт angee init --dev angee stack init --template stacks/local <folder>
В git .angee/ в .gitignore, регенерируется на клон это и есть деплой-артефакт
Назначение итерации по коду, который уже есть на диске локальный/staging/prod-инстанс, хост для workspace'ов

Документация фиксирует и анти-паттерн: рендерить stack в корень самого приложения (ANGEE_ROOT: . для dev-шаблона) — значит смешать два .copier-answers.yml, стековый и проектный, и потерять возможность обновить любой из них. Приложение — это Source, а не Stack.


8. Компиляция стека: Compile

Ядро — чистая функция:

func Compile(stack *manifest.Stack, root string, resolvedSecrets map[string]string) (*CompiledStack, error)

Ни ввода-вывода, ни сети. На входе — распарсенный манифест, корень и уже резолвленные секреты; на выходе — две модели документов и карта имён env-переменных секретов.

Порядок детерминирован

Всякий обход map'а идёт через sortedKeys. Это не косметика: docker-compose.yaml — файл, который читает человек и который лежит рядом с исходниками; недетерминированный порядок ключей давал бы ложный diff на каждой перекомпиляции.

Идентичность compose-проекта

Name: composeProjectName(stack.Name, root)

Не stack.Name. Комментарий объясняет причину предельно точно: имя compose-проекта — глобальное пространство имён демона Docker, оно префиксует имена контейнеров, сетей и томов; а stack.Name — человекочитаемая метка, которая у всех dev-workspace'ов одинакова. Два стека с одним именем слились бы в один проект даже в разных каталогах под разными операторами. Поэтому имя строится как <sanitized-name-до-30-символов>-<первые-4-байта-sha256-от-абсолютного-корня-в-hex>, например notes-angee-1a2b3c4d. Читаемо и уникально.

Ветвление по runtime

Для каждого сервиса раскрываются подстановки в env, command, ports, mounts, workdir, после чего:

  • runtime: containercompose.Service с image/build/command/environment/ports/volumes/working_dir/depends_on. Mount-URI переводятся в строки host:target[:ro].
  • runtime: localproccompose.Process с shell-строкой команды, env-списком, рабочим каталогом и зависимостями. Mount-URI не монтируются (некуда), а превращаются в переменные окружения: workspace://pr-42/appWORKSPACE_PR_42_APP_PATH=/abs/path. Это единственный способ дать локальному процессу узнать, где лежит смонтированный ресурс.

Условие зависимости выводится из вида объекта

condition := "service_started"
if _, ok := stack.Jobs[name]; ok {
    condition = "service_completed_successfully"
}

depends_on на сервис означает «запустился», на job — «завершился успешно». Одна и та же запись в манифесте компилируется в разные условия в зависимости от того, чем является цель. Симметрично для process-compose (process_started / process_completed_successfully).

Квотирование shell-команд

Локальные процессы получают команду строкой, поэтому shellCommand квотирует каждый аргумент: если он состоит только из «безопасных» рун ([A-Za-z0-9_+-=./:]) — как есть, иначе оборачивается в одинарные кавычки с экранированием внутренних. Это защита от инъекции через значение секрета или пользовательский ввод, попавший в command.

Ingress дописывается последним

edgeBackend, _ := edge.FromManifest(stack.Ingress)
edgeBackend.Contribute(stack, &compiled.Compose)

Edge — не отдельный слой компиляции, а мутирующий вклад в уже скомпилированный compose. Это позволяет добавить новый тип edge (traefik, nginx) одной реализацией интерфейса без изменения основного компилятора. При ingress.type: none вклад пустой.

Два пути записи

Здесь есть архитектурно значимая двойственность:

  • writeCompiled — прямая запись os.WriteFile. Быстрый путь для StackPrepare/up/build.
  • runtimeArtifactDocuments — та же генерация, но возвращающая карты path → bytes, path → delete, path → mode относительно render-target. Используется, когда сгенерированные файлы должны пройти через транзакционный apply вместе с шаблонным рендером (stack update --template).

Второй путь дополнительно проверяет, что артефакт не выходит за пределы render-target, и умеет удалять файл: если после изменения манифеста не осталось ни одного контейнерного сервиса, docker-compose.yaml не остаётся висеть устаревшим — он удаляется.

Отдельная тонкость в run/secrets.env: он пишется только при secrets_backend: openbao. Если backend сменили обратно на env-file, старый файл удаляется — но перед удалением код проверяет через os.SameFile, не указывает ли активный env-file на тот же inode, чтобы не стереть настоящие секреты пользователя.


9. Язык подстановок

internal/substitute реализует грамматику ${namespace.path | filter | filter(arg)} регуляркой \$\{([^{}]+)\} — без вложенности, намеренно.

Пространства имён

Namespace Пример Значение
secret ${secret.db_password} значение или ссылка на env-переменную (см. ниже)
service ${service.api.url}, ${service.api.port} адресация другого сервиса
ports ${ports.web} объявленный фиксированный порт
alloc ${alloc.django} порт, выделенный из пула для этого объекта
workspace ${workspace.pr-42}, ${workspace.path} путь к workspace'у
source ${source.app} путь к материализованному источнику
persist ${persist.browser-data} путь к персистентному каталогу
operator ${operator.url}, ${operator.domain} координаты control plane
inputs ${inputs.branch} вход шаблона/задания
name ${name} имя текущего сервиса/задания

Фильтры

slug, lower, upper, local_part (часть email до @), truncate(n), и другие — применяются слева направо к результату резолва.

Ключевая деталь: двойная семантика ${secret.*}

case "secret":
    if env, ok := ctx.SecretEnvVars[rest]; ok {
        return "${" + env + "}", nil          // → в compose уходит ССЫЛКА
    }
    value, ok := ctx.Secrets[rest]            // → иначе подставляется ЗНАЧЕНИЕ

Это решает конкретную проблему: секрет не должен попадать в docker-compose.yaml, который лежит на диске рядом с исходниками. При компиляции compose передаётся карта SecretEnvVars, и ${secret.db_password} превращается в ${ANGEE_SECRET_DB_PASSWORD} — compose-переменную, которую docker подставит из --env-file в момент запуска. А при выполнении job'а (JobRun) карта не передаётся, и то же выражение резолвится в реальное значение, потому что процесс запускается напрямую.

Одна грамматика, два режима, выбираемые вызывающей стороной. Секрет никогда не оказывается в файле, который переживает процесс.

SecretRefs: обратный разбор

Функция сканирует произвольные строки и возвращает имена секретов, на которые они ссылаются, — та же грамматика, но в режиме извлечения. Используется ServiceCreate, чтобы автоматически задекларировать секреты, которые упоминает отрендеренный шаблон сервиса: тогда per-agent-токен, положенный через secretSet, резолвится на компиляции без явной декларации в шаблоне. (Для job'ов эта симметрия ещё не сделана — открытый пункт в .agents/notes/todo.md.)


10. Mount URI

internal/mount — 177 строк, четыре схемы, два режима резолва.

workspace://<name>[/<subpath>]:<target>[:ro]
source://<name>[/<subpath>]:<target>[:ro]
volume://<name>:<target>[:ro]
bind://<host-path>:<target>[:ro]

ResolveContainer даёт compose-строку. ResolveLocalEnv даёт пару (ENV_NAME, path). ResolveWorkdir резолвит URI, использованный как рабочий каталог.

Правила, закреплённые кодом:

  • целевой путь обязан быть абсолютным;
  • volume:// не поддерживает subpath (том — непрозрачная сущность);
  • bind:// с относительным путём получает явный префикс ./, потому что compose иначе трактует такое значение как имя именованного тома;
  • имя env-переменной строится санитизацией: SOURCE/WORKSPACE/VOLUME/BIND + имя + сегменты subpath + PATH, всё в upper-snake.

Резолвер (mount.Resolver) — три map'а (Workspaces, Sources, Volumes), собираемые в resourceResolver из манифеста. Для local-источника берётся объявленный path, для gitcache_path или дефолтный sources/<name>.


11. Runtime-backend'ы

type Backend interface {
    Build(ctx, Target) error
    Up(ctx, Target) error
    UpForeground(ctx, Target, stdout, stderr io.Writer) error
    Down(ctx, Target) error
    Start(ctx, Target) error
    Stop(ctx, Target) error
    Restart(ctx, Target) error
    Logs(ctx, LogsRequest) (<-chan string, error)
    StreamLogs(ctx, LogsRequest) (<-chan string, error)
    Status(ctx, StatusRequest) ([]ServiceStatus, error)
}

Platform держит два экземпляра — composeBackend и procBackend — и раздаёт им работу по признаку runtime в манифесте. StackStatus сливает наблюдаемые состояния обоих в одну карту, при этом ошибки обоих намеренно проглатываются: невыстроенный compose-файл, недоступный docker-демон и неподнятый process-compose-супервизор — все три легитимно означают «не наблюдается запущенным», и сервис получает sentinel-статус declared.

Runner-шов для тестируемости

Оба backend'а параметризованы интерфейсом Runner:

type Runner interface {
    Run(ctx context.Context, dir string, name string, args ...string) ([]byte, error)
}

Продакшн-реализация ExecRunner шеллится наружу. Тесты подставляют фейк и проверяют точную командную строку. Код при этом умеет различать одно от другого:

if b.Runner != nil && !isExecRunner(b.Runner) {
    out, err := b.run(ctx, req.Root, args...)
    return runtime.ReplayLines(ctx, out), nil     // тестовый путь: воспроизвести захваченный вывод
}
cmd := exec.CommandContext(ctx, "docker", args...)
return runtime.StreamCommand(ctx, cmd)            // боевой путь: живой стрим

Приём слегка нарушает чистоту (реализация знает про тестовый двойник), но покупает возможность юнит-тестировать стриминговые пути без запуска docker.

Logs против StreamLogs

Два разных контракта:

  • Logsограниченное чтение: буферизует вывод целиком, отдаёт одним элементом канала. Есть MaxBytes с limitedBuffer, дописывающим \n[truncated]\n. Это путь для GraphQL-снапшота логов и REST-выдачи.
  • StreamLogsживой follow: одна строка на элемент канала по мере появления.

StreamCommand: аккуратность на уровне файловых дескрипторов

Функция в internal/runtime/stream.go заслуживает отдельного упоминания как образец того, как в этом репозитории пишут concurrency:

  • stdout и stderr сводятся в один os.Pipe, чтобы порядок строк сохранялся;
  • родительская копия write-end закрывается сразу после Start, чтобы reader увидел EOF, когда выйдет ребёнок;
  • watcher-горутина закрывает read-end по отмене контекста — потому что убитый ребёнок мог оставить внука, унаследовавшего дескриптор, и Scan() завис бы в Read навсегда;
  • буфер сканера поднят до 1 МиБ, чтобы патологически длинная строка не оборвала стрим по дефолтному лимиту 64 КиБ;
  • код выхода процесса намеренно игнорируется: убитый follow-процесс, вышедший ненулём, — это нормальное завершение стрима.

Грациозная остановка

runtime.GracefulWaitDelay = 10s — общая константа для обоих backend'ов, «чтобы период отсрочки не разъехался между ними». Прикреплённый docker compose up получает SIGINT (через cmd.Cancel), а не SIGKILL, и WaitDelay ограничивает грацию. Отмена контекста при graceful не считается ошибкой — это штатный выход из angee dev.

Специфика process-compose

Backend знает неочевидные вещи про свой супервизор и документирует их прямо в коде:

  • up -d --tui=false — иначе супервизор пытается прицепить TUI к процессу без управляющего терминала;
  • down — это клиентская команда: она подключается к живому супервизору по control-порту, и передавать ей -f нельзя (v2 выведет help и выйдет с кодом 0 — молчаливый no-op);
  • есть путь автоустановки process-compose через go install при отсутствии бинаря.

Control-порт берётся из манифеста (processComposeControlPort), дефолт 8080, — чтобы параллельные стеки на одном хосте имели разные супервизоры.


12. Ingress: Caddy-edge

internal/runtime/edge реализует единственный сегодня edge-backend поверх lucaslorentz/caddy-docker-proxy. Он дописывает в скомпилированный compose:

  1. сеть <stack>_edge (или ingress.network);
  2. сервис edge с образом caddy-docker-proxy, публикацией портов и монтированием docker-сокета в read-only;
  3. для каждого сервиса с route: — снятие собственных ports (наружу он больше не торчит), присоединение к edge-сети и набор Caddy-labels.

Два режима маршрутизации

Host-режим (по умолчанию): один сайт на сервис.

labels:
  caddy: "api.example.com"
  caddy.reverse_proxy: "{{upstreams 8000}}"
  caddy.reverse_proxy.flush_interval: "-1"
  caddy.forward_auth: "operator:9000"
  caddy.forward_auth.uri: "/edge/verify?service=api"

Path-режим: один общий сайт с handle_path-блоком на сервис.

labels:
  caddy: "example.com"
  caddy.0_handle_path: "/api/*"
  caddy.0_handle_path.reverse_proxy: "{{upstreams 8000}}"

Числовой префикс (0_, 1_, …) — не украшение: ключи labels должны быть уникальны между контейнерами, иначе caddy-docker-proxy перезапишет, а не смержит блоки. Индекс назначается детерминированно, по алфавиту имён маршрутизируемых сервисов (routedServiceIndex).

flush_interval: -1 выставлен всегда — это отключение буферизации, необходимое для SSE и WebSocket.

forward_auth как единая точка авторизации

Каждый маршрут (кроме route.auth: none) получает forward_auth на /edge/verify?service=<name> оператора. Оператор проверяет, что предъявленный токен имеет аудиторию svc:<name>, и отвечает 200/401.

Извлечение токена в edgeToken учитывает специфику forward_auth: клиентский URI с ?token= приезжает в заголовке X-Forwarded-Uri, тогда как r.URL — это уже подзапрос /edge/verify. Порядок поиска: X-Forwarded-Uri → собственный query → Authorization: BearerSec-WebSocket-Protocol (браузерный WebSocket не умеет ставить заголовки, поэтому токен едет в подпротоколе).

TLS и порт

tls: auto (по умолчанию) — Caddy сам получает сертификаты, edge держит 443/80. tls: off — plain HTTP, и тогда становится осмысленным ingress.port: несколько параллельных dev-стеков на одном хосте не могут все занять :80. URL, который отдаёт serviceEndpoint, несёт тот же порт — «решение о том, что считать портом по умолчанию, живёт в одном месте» (manifest.DefaultEdgePort).


13. Секреты

type Backend interface {
    Get(ctx, key) (string, bool, error)
    Set(ctx, key, value) error
    Delete(ctx, key) error
    List(ctx) ([]string, error)
}

Две реализации: envfile (файл KEY=VALUE, по умолчанию .env в корне) и openbao (KV v2 по HTTP).

Декларации и их резолв

secrets:
  db_password:
    generated: true
    length: 48
  github_token:
    required: true
  home_dir:
    import: HOME

secrets.ResolveDeclarations обходит декларации и:

  • import: VAR → берёт из окружения процесса;
  • generated: true → если в backend'е ничего нет, генерирует и записывает обратно (идемпотентность: второй запуск получит то же значение);
  • required: true → отсутствие значения — ошибка;
  • иначе — читает из backend'а.

keyMapper и env-имена

FromManifest передаёт env-file-backend'у substitute.SecretEnvName как маппер ключей: логическое имя db_password хранится в файле как ANGEE_SECRET_DB_PASSWORD. Это делает .env пригодным для прямой передачи в docker compose --env-file — тот же файл одновременно является хранилищем секретов и источником compose-переменных.

При OpenBao такого совмещения нет, поэтому резолвленные значения материализуются в отдельный run/secrets.env с правами 0600, и именно он передаётся в --env-file.

Bootstrap OpenBao: самозагрузка хранилища

bootstrapOpenBao решает курицу-и-яйцо: если backend — OpenBao, а сам OpenBao объявлен как контейнерный сервис в этом же стеке, то перед резолвом секретов надо поднять именно его. Код строит урезанную копию стека (без секретов, без job'ов, единственный сервис openbao), компилирует её, поднимает и ждёт готовности, после чего продолжает обычный путь. Копия делается по значению (bootstrap := *stack), исходный манифест не мутируется.

Секреты как store-backend

internal/secrets/store_register.go регистрирует env-file и openbao ещё и в реестре internal/storeне для того, чтобы перенаправить текущий трафик секретов, а чтобы будущая миграция (файлы поверх OpenBao, секреты через store) была одним вызовом store.Open. Комментарий явно отмечает, что адаптеры не реализуют store.Versioned: секреты — last-write-wins, CAS/etag — это забота файлов на localfs.

Это характерный для репозитория приём: шов объявляется до того, как понадобится, но остаётся неподключённым и явно помеченным.


14. Порты

internal/ports.Pool — 116 строк, диапазон + карта аренд, защищённая мьютексом.

Три особенности, каждая отвечает на реальную проблему:

  1. Идемпотентность по владельцу. Allocate(owner) сначала ищет уже выданную этому владельцу аренду и возвращает её. Повторный workspaceUpdate не сдвигает порты.
  2. Проба хоста вне мьютекса. AllocateAvailable(owner, unavailable) собирает кандидатов под локом, отпускает лок, затем вызывает предикат, который делает I/O. Комментарий объясняет: предикат может ходить в сеть, держать под ним мьютекс нельзя. После успешной пробы аренда перепроверяется под локом (double-check).
  3. Проба на двух стеках. hostPortUnavailable слушает tcp4 0.0.0.0 (авторитетная проверка — docker публикует туда) и tcp6 [::] (совещательная). Отказ tcp6 c EAFNOSUPPORT игнорируется, иначе на хосте без IPv6 весь пул считался бы занятым.

Аренды сериализуются в port_leases манифеста и восстанавливаются при загрузке (ports.FromManifest), с проверкой, что аренда не выходит за границы своего пула.


15. Шаблоны: _angee-метаданные и разрешение ссылок

Шаблон — это каталог с copier.yml. Angee читает из него только те ключи, которые ему нужны, плюс собственный блок _angee:

_subdirectory: template
_templates_suffix: .jinja
_answers_file: .copier-answers.yml
_angee:
  kind: workspace            # stack | workspace | service | (project — не рендерится оператором)
  name: pr
  instance_naming:
    pattern: "${inputs.branch | slug | truncate(40)}"
  inputs:
    branch: {type: string, required: true}
  sources:
    app: {source: app, mode: worktree, branch: "${inputs.branch}", subpath: app}
  chain_root: stack
  chain:
    - {template: stacks/dev, root: stack}
  ensure:
    operator.port_pool.workspace: {range: "8100-8199"}
  persist:
    browser-data: {subpath: .browser-data, scope: workspace}

Три вида входов

Свойство Смысл
required отсутствие — ошибка preflight
generated + length если не задан, генерируется криптослучайная base64url-строка
immutable не должен меняться при обновлении
type: path особый тип, включающий path-резолюцию Angee

type: path заслуживает пояснения. Пользователь вводит логический путь относительно места рендера; ResolvePathInputs переводит его в путь относительно ANGEE_ROOT, потому что именно так его потом будет резолвить manifest.ResolvePath. Для dev-overlay это порождает естественный ..-escape (framework_path: ..). Абсолютные пути проходят как есть. Результат: записанный в манифест путь портативен между машинами.

Разрешение ссылки на шаблон

Локальный поиск (resolveTemplate) перебирает по порядку:

$ANGEE_ROOT/.templates/<kind>/<name>
$ANGEE_ROOT/templates/<kind>/<name>
$ANGEE_ROOT/<kind>/<name>
$ANGEE_ROOT/<name>
<предок ANGEE_ROOT>/.templates/<kind>/<name>     # до 32 уровней вверх
$PWD/.templates/<kind>/<name>
$PWD/templates/<kind>/<name>
<предок PWD>/.templates/<kind>/<name>

Подъём по предкам (ancestorTemplatePaths, ограничен 32 уровнями как предохранитель) нужен для монорепозиториев: команда, запущенная из <repo>/examples/foo/, находит <repo>/.templates/stacks/dev.

Удалённое разрешение поддерживает HTTPS-ссылки на GitHub:

angee stack init https://github.com/example/templates/tree/main/.templates/stacks/dev

parseGitHubTemplateRef разбирает owner/repo/branch/subpath (ветка также может приехать в ?ref=), репозиторий клонируется в пользовательский кэш ($XDG_CACHE/angee/templates/<sha256-от-ref>), при повторном обращении — fetch + checkout. Есть послабление: если по указанному пути нет copier.yml, пробуется вариант с плюрализованным kind (stackstacks).

Хост ограничен github.com — фактически whitelist, а не общий git-резолвер.

Service-шаблоны

Отдельный kind со своим контрактом: шаблон обязан отрендерить service.yaml ровно с одной записью под services:; всё остальное (jobs, volumes, secrets, sources) отвергается. Прочие файлы дерева (обычно docker/Dockerfile) реконсилируются в <root>/services/<service_name>/, чтобы отрендеренный build.context: ./services/<name>/docker резолвился.

Имя сервиса выводится из name_pattern (дефолт agent-${workspace.name}) и валидируется регуляркой, совместимой одновременно с именами compose-сервисов и process-compose-процессов.


16. Stateful-реконсиляция рендера

internal/copierx/reconcile.go — 2776 строк, второй по объёму рукописный файл репозитория. Это ответ на вопрос: как повторно отрендерить шаблон в каталог, который человек с тех пор редактировал, не потеряв ни его правки, ни обновления шаблона?

Состояние рендера

type RenderState struct {
    Version        int
    Layers         []RenderLayerState
    Files          map[string]Fingerprint
    Documents      map[string][]byte
    ProtectedPaths []string
}

type Fingerprint struct {
    Kind   string      // file | symlink | directory | other
    SHA256 string
    Mode   fs.FileMode
    Link   string      // цель симлинка
}

Состояние хранится в <root>/run/template-state/<kind>/<name>.json и версионировано. Ключевое: симлинк — сущность первого класса, он фингерпринтится по цели ссылки, а не по содержимому. Это то, что позволило поддержать Copier-шаблоны с _preserve_symlinks: true (см. CHANGELOG v0.8.3 — до этого такие шаблоны просто отвергались, хотя весь пайплайн их уже умел).

Трёхсторонняя логика

Для каждого пути известны три состояния: old (что рендерилось прошлый раз), current (что сейчас на диске), new (что отрендерилось сейчас). reconcilePath выдаёт либо Change, либо Conflict:

Ситуация Исход
файла не было, отрендерился add
был, диск совпадает с old, шаблон изменился modify
был, диск совпадает с old, шаблон больше не рендерит delete
диска нет в состоянии, но содержимое идентично new adopt (усыновление, не считается изменением)
диск отличается от old и от new Conflict{locally-modified}
файла не было в состоянии, диск отличается от new Conflict{untracked-different}
сменился вид записи (файл ↔ симлинк ↔ каталог) Conflict{type-changed}

Overwrite: true разрешает конфликты в пользу шаблона. DryRun: true считает всё то же самое, но не пишет — на этом построены --dry-run во всех командах обновления.

adopt — тонкий, но практически важный кейс: файл существует, в состоянии его нет, но байты совпадают с тем, что отрендерилось. Это первый прогон реконсиляции над каталогом, созданным старой версией без состояния. Он «усыновляется» и не считается изменением, поэтому stack update --template над неизменившимся деревом честно рапортует changed: false.

Жизненный цикл PreparedReconcile

prepared, err := copierx.PrepareReconcile(ctx, plan, opts)   // рендер в scratch, расчёт diff'а
defer prepared.Close()                                        // scratch/backup убраны, capability закрыты

rollback, err := prepared.ApplyFiles(ctx)                     // запись + журнал отката
...
if !committed { rollback() }

prepared.SaveState(ctx)                                       // фиксация нового состояния

Рендер идёт во временный каталог (os.MkdirTemp("", "angee-reconcile-*")), а не в целевой. Только после того, как весь diff посчитан и все конфликты выявлены, начинается запись. ApplyFiles ведёт журнал (applyJournalEntry) и возвращает замыкание-откат.

Дополнительно есть pruneEmptyParents — удаление каталогов, которые apply создал и которые после отката остались бы пустыми. Откат восстанавливает не только файлы, но и топологию каталогов.

Слои

RenderPlan.Layers — упорядоченный список рендеров в одну цель. Workspace-шаблон плюс зачейненный stack-шаблон — это два слоя одного плана, с общим состоянием и общим транзакционным apply. Поэтому «частично отрендеренного» workspace'а не бывает: либо оба слоя легли, либо не лёг ни один.


17. Capability-safe работа с файловой системой

Это самый нестандартный для Go-приложения слой, и он объясняет, почему reconcile.go такой большой.

Проблема

Оператор пишет в каталоги, где одновременно: работает человек в редакторе, работает агент, работает git, работает docker. Классический filepath.Join(root, userPath) + os.WriteFile уязвим к TOCTOU: между проверкой «путь внутри корня» и записью кто-то может подменить промежуточный компонент на симлинк наружу.

Решение

Два типа поверх os.Root (Go 1.24+, гарантирует, что все операции остаются внутри открытого корня):

type TrustedRoot struct { ... }   // удерживаемая capability на каталог
type GuardedPath struct { ... }   // capability на конкретную запись внутри корня

Свойства, которые они обеспечивают:

  • открытие вместо разрешения: openVerifiedRoot открывает каталог и валидирует, что ни один родительский компонент не является симлинком (validateRootParents);
  • захват идентичности: captureEntry запоминает fs.FileInfo записи; verifyEntry сверяет её позже;
  • verify-after-write: VerifyPathIdentity, VerifyPathEntryIdentity, VerifyPathAbsent, VerifyParentPathIdentity, VerifyParentTrustedRoot — набор пост-проверок, которые вызываются после записи и до коммита. Если под нами подменили путь — операция откатывается;
  • явный allow-list симлинк-родителей: AllowedSymlinkParents map[string]*TrustedRoot. Симлинк в родителях пути по умолчанию запрещён; исключение делается только для объявленных local-источников, и их разрешённая цель предварительно верифицируется;
  • атомарная запись: WriteFile пишет во временное имя внутри того же rooted-каталога и переименовывает;
  • moveExpectedAside / restoreAside: перед разрушающей операцией существующая запись отодвигается, а не удаляется, — чтобы откат был возможен.

Цена и оправдание

Слой стоит примерно 1500 строк и делает код заметно многословнее: почти каждая функция домена, пишущая на диск, возвращает тройку (rollback, close, verify func() error). Например:

rollbackPersist, closePersist, verifyPersist, err := materializePersistPaths(...)

и складывается лесенкой defer-ов с joinRollbackErrors.

Оправдание прямое: угроза реальна (агент с правом писать в workspace может создать симлинк, который выведет следующий рендер за пределы корня), а последствия — запись оператора в произвольное место файловой системы под правами пользователя.


18. Транзакционность и блокировки

Блокировка корня

internal/fslock — advisory-блокировка файла <root>/run/operator.lock через flock, с ожиданием на тикере 10 мс и уважением контекста:

lock := fslock.RootLock(p.root)
err := lock.With(ctx, func() error { ... })

Блокировка нерекурсивна, и это явно учитывается в коде. ServiceCreate держит лок только на цикл «загрузить → выделить порты → проверить уникальность → записать манифест», после чего освобождает его перед вызовом StackPrepare/ServiceUp, которые берут тот же лок внутри. Комментарий фиксирует и последствие: если перекомпиляция после освобождения лока упадёт, запись в манифесте останется, и восстановление — через angee service destroy.

Транзакция родительского манифеста

parentTx, stack, err := openParentStackTransaction(p.root, true)
defer parentTx.Close()
...
if err := parentTx.Save(stack); err != nil { ... }

Save перед записью перечитывает файл и сверяет его с байтами, снятыми при открытии транзакции:

if exists != t.existed || exists && (!bytes.Equal(current, t.before) || info.Mode().Perm() != t.mode.Perm()) {
    return fmt.Errorf("parent stack manifest changed during template reconciliation")
}

Это оптимистическая блокировка поверх файла. Rollback восстанавливает прежние байты и права, а если файла не было — удаляет его и подчищает созданные родительские каталоги.

Лестница откатов

WorkspaceCreate — эталонный пример композиции. В нём последовательно накапливается пять независимых откатов:

committed := false
defer func() { if !committed { retErr = joinRollbackErrors(retErr, parentTx.Rollback, removeWorkspace) } }()
defer func() { /* sourceCleanup.Rollback со свежим контекстом на 30 с */ }()
defer func() { if !committed { retErr = joinRollbackErrors(retErr, rollbackTemplateFiles) } }()
defer func() { if !committed { retErr = joinRollbackErrors(retErr, rollbackTemplateDocuments) } }()
defer func() { if !committed { retErr = joinRollbackErrors(retErr, rollbackPersistPaths) } }()

Три детали здесь неслучайны:

  1. removeWorkspace условен. Если каталог workspace'а уже существовал (остаток от прошлой неудачной попытки), откат его не удаляет — удаляются только worktree'ы, которые создала именно эта попытка. Логика «не разрушать то, чего мы не создавали».
  2. Очистка источников идёт со свежим контекстом (context.WithoutCancel(ctx) + таймаут 30 с), потому что типичная причина отката — отмена исходного контекста, и наследование его сделало бы очистку невозможной.
  3. Порядок фиксации: сначала verify-проверки всех capability, потом parentTx.Save, потом prepared.SaveState, и только затем committed = true. Если SaveState упал — манифест откатывается.

19. Git-слой

internal/git — гибрид, и его doc-comment объясняет принцип разделения:

Read-only queries (status, refs) are implemented with go-git where possible so they avoid spawning a process per call. Config, ahead/behind, and write or network operations shell out to the git CLI so they inherit the user's credential helpers, SSH config, config includes, and upstream git's precedence and graph semantics.

Это правильная граница. go-git быстрый, но его модель конфигурации и учётных данных не совпадает с пользовательской; клон приватного репозитория обязан пройти через тот же credential.helper, что и ручной git clone. Поэтому:

Через go-git Через git CLI
RefExists, CurrentRef, CurrentBranch, Upstream, Remotes, Dirty Clone, CloneRef, Fetch, Pull, Push, PushSetUpstream
Merge, Rebase, WorktreeAdd/Remove/Prune, Config, AheadBehind

Причём для каждого read-only метода есть CLI-фолбэк (currentRefCLI, upstreamCLI, dirtyCLI, …) на случай, когда go-git не справился.

Worktree-хозяйство

Здесь сосредоточена нетривиальная операционная мудрость, зафиксированная в комментариях:

  • WorktreeRemove использует двойной --force: снимает worktree даже если он заблокирован и даже если его рабочее дерево уже исчезло, — потому что и reclaim, и rollback всегда означают «выбросить остаток»;
  • WorktreePrune — отдельный портативный путь восстановления для старых git, которые отказываются удалять worktree с пропавшим деревом; вызывается только когда add реально конфликтует, не превентивно;
  • WorktreeRegistered + canonicalWorktreePath — сверка регистрации по каноническому пути (важно на macOS с /var/private/var);
  • repairStagedWorktreeRegistration в workspaces.go — восстановление регистрации, когда worktree был подготовлен в staging-каталоге и затем перемещён.

Состояние источника

SyncBaseRef вычисляет базовую ветку для sync-операций, разбираясь с квалификацией по remote (remoteQualifiedRef). AheadBehind даёт пару счётчиков относительно базы. Из них и Dirty собирается состояние, которое видит клиент: clean / dirty / ahead / behind / diverged / branch-mismatch.


20. Workspace'ы: жизненный цикл

WorkspaceCreate — 200 строк оркестрации, самая сложная операция домена. Порядок:

1.  открыть capability на корень стека, убедиться что это реальный каталог
2.  открыть транзакцию родительского манифеста
3.  разрешить шаблон, провалидировать kind == workspace
4.  manifest.Ensure(stack, metadata.Ensure)          — требования шаблона к стеку
5.  слить входы (defaults + generated + переданные)
6.  вычислить имя (instance_naming.pattern | явное | fallback)
7.  проверить уникальность
8.  выделить порты из объявленных пулов (owner = "workspace/<name>")
9.  открыть capability на workspaces/<name>, запомнить существовал ли он
10. материализовать источники (worktree/local/clone) → cleanup-объект
11. построить RenderPlan (workspace-слой + chain-слои)
12. PrepareReconcile (ReconcileCreate)
13. verify: capability корня всё ещё та же
14. ApplyFiles → rollbackTemplateFiles
15. извлечь отрендеренные stack-документы, распарсить каждый как манифест
16. applyRenderedDocuments → rollbackTemplateDocuments
17. materializePersistPaths → rollbackPersistPaths
18. собрать manifest.Workspace (template, inputs, sources, resolved, TTL)
19. verify всех capability
20. parentTx.Save(stack)
21. prepared.SaveState(ctx)
22. committed = true

Упорядочивание материализаций источников

orderWorkspaceSourceMaterializations сортирует слоты так, чтобы вложенные назначения создавались после родительских (sameOrNestedPath). Это нужно, когда один слот монтируется внутрь другого (subpath).

Persist-пути

persist: объявляет каталоги, которые переживают пересоздание workspace'а. scope: workspace резолвит их относительно каталога workspace'а; на уровне стека (stack.Persist) subpath'ы считаются от родителя ANGEE_ROOT — «matching the workspace-create convention». Материализация идёт через те же guarded-capability, что и всё остальное.

TTL

ttl: 72h записывает ttl_expires_at как абсолютную метку UTC. Оператор не имеет сборщика мусора — TTL сегодня чисто информационный, потребитель сам решает, что делать с истёкшими. Это честный, но заметный пробел (см. §39).

WorkspaceStatus: агрегированное состояние

Статус собирает по каждому слоту: путь, объявленную ветку, текущий ref, upstream, ahead/behind, dirty, pushed, состояние. Верхнеуровневое поле state становится discrepancy, если хоть один слот в branch-mismatch. Дополнительно вычисляется mountedBy — какие сервисы и job'ы стека монтируют этот workspace (сканированием mount-URI, workdir и env), что даёт клиенту обратную связь «кто это использует».

Освобождение ресурсов при удалении

WorkspaceDestroy снимает регистрацию worktree'ев, освобождает аренды портов (releaseWorkspacePorts), удаляет запись из манифеста и, при --purge, само дерево.


21. GitOps-топология и три области обновления

Производное представление

GitOpsTopology — read-only агрегат: декартово соединение источников и слотов workspace'ов, посчитанное на лету, без кэша. Даёт sources[], links[] (пара источник × слот) и summary со счётчиками clean/dirty/ahead/behind/diverged/pushed.

GitOpsTopologyWithCommits(n) дополнительно подтягивает историю коммитов. Здесь есть аккуратность: n отрицательный отвергается, а сверху зажимается gitOpsTopologyCommitsMax — «чтобы враждебный или багованный withCommits=1e9 не развернул длинный обход git log по каждому git-источнику».

Три «обновления», названные по-разному

Это редкий пример терминологической дисциплины, доведённой до API:

Область Операция Что делает
Весь источник sourcePull(name) fetch + fast-forward кэша источника
Один слот workspaceSourcePull(ws, slot) fast-forward worktree'а слота от его tracking-ref
Все слоты workspace'а workspaceSyncBase(ws, method) merge или rebase каждой ветки workspace'а на её базовый ref

workspaceSyncBase не переключает ветку — он остаётся на ветке workspace'а и подтягивает в неё базу (обычно origin/main). Это операция «оставаться актуальным относительно main», и она принципиально отличается от pull, который двигает ветку к её собственному upstream.

Полный набор конвергенции на уровне слота: merge, rebase, merge-abort, rebase-abort, rebase-continue, publish (первая публикация с --set-upstream). Каждая возвращает api.GitOpResult со статусом и, при конфликте, списком конфликтующих файлов — то есть конфликт слияния является нормальным возвращаемым значением, а не ошибкой транспорта.

Diff

SourceDiff / WorkspaceSourceDiff возвращают структурированный []api.DiffFile с хунками, разобранными через bluekeyes/go-gitdiff, а не сырой текст патча. Это делает diff пригодным для рендеринга в UI без парсинга на клиенте.


22. Обновление из шаблона: stack update --template

Одна из наиболее концептуально нагруженных операций. Задача: перерендерить angee.yaml из шаблона так, чтобы:

  • обновились секции, происходящие из шаблона;
  • сохранились пользовательские добавления;
  • не потерялось операторское runtime-состояние.

Восстановление входов

Входы берутся не из воздуха, а из .copier-answers.yml. Файл ищется и в самом ANGEE_ROOT, и в его родителе (для dev-overlay angee.yaml лежит в .angee/, а answers — уровнем выше), причём родительский принимается только если записанный в нём ANGEE_ROOT указывает обратно на этот корень — иначе можно было бы подхватить answers чужого проекта. Отсутствие answers — жёсткая ошибка с внятной подсказкой, а не тихий откат к дефолтам шаблона.

Перекрытие портов для внутреннего стека workspace'а

Тонкий случай, разобранный в коде явно: у внутреннего стека workspace'а выделенные порты принадлежат записи workspace'а в родительском манифесте, а не его собственному замороженному answers-файлу. Copier может сбросить answers к дефолтам шаблона и молча уронить аллокацию. Поэтому если стек опознан как управляемый внутренний стек workspace'а, авторитетные порты накладываются поверх answers — а все остальные входы (project, sources, …) по-прежнему берутся из answers, чтобы устаревшая запись workspace'а не могла их перенаправить.

Что сохраняется дословно

Секция Судьба при re-render
operator, workspaces, port_leases сохраняются как есть (runtime-состояние оператора)
ports значения аллокаций сохраняются
остальные секции шаблонного происхождения обновляются из шаблона
ключи, добавленные пользователем сохраняются

Слияние структурное: списки и скаляры атомарны, map'ы (env, build, route) сливаются рекурсивно. Конфликт по одному и тому же полю сообщается, а не разрешается молча; --overwrite выбирает шаблонное значение.

Тот же механизм применяется в ServiceUpdateFromTemplate — с сохранением имени сервиса, привязки к workspace'у и выделенных портов как авторитетных из текущего состояния.


23. Задания

Job — это Service без долгой жизни, но с двумя различиями в реализации:

  1. Только runtime: local job'ы попадают в process-compose.yaml — там они существуют как процессы, на которые могут ссылаться depends_on (с условием process_completed_successfully). Это механизм «миграции перед стартом приложения».
  2. JobRun для local-job'а не идёт через супервизор вовсе — он резолвит секреты, раскрывает подстановки (с inputs из запроса), резолвит workdir и запускает процесс напрямую через os/exec, возвращая захваченный вывод.

Именно во втором пути ${secret.*} резолвится в значение, а не в ссылку на env-переменную (см. §9).

Живой вывод задания

Platform принимает опцию WithJobOutput(io.Writer). Оператор передаёт свой stdout, CLI — нет. В результате:

  • запущенный оператором job печатает вывод живьём в терминал демона, с маркерами [job <name>] running|finished|failed, чтобы параллельные задания читались на общем терминале;
  • вызывающая сторона всё равно получает захваченный вывод целиком;
  • CLI сохраняет прежнее поведение — один буферизованный вывод в конце.

Это иллюстрация того, как в кодовой базе решаются различия между транспортами: не ветвлением внутри домена, а опцией конструктора.


24. service.API — единый контракт control plane

Файл internal/service/api.go — 169 строк, из них большая часть — комментарии, объясняющие границы. Это архитектурный центр репозитория.

type API interface {
    StackAPI; RuntimeAPI; ServiceAPI; JobAPI
    WorkspaceAPI; SourceAPI; WorkspaceSourceAPI; GitOpsAPI
    SecretsAPI; FileAPI; IngressAPI; TemplateAPI
}

Двенадцать под-интерфейсов по доменным осям. Две реализации:

var _ API = (*Platform)(nil)          // internal/service — in-process
var _ API = (*RemoteClient)(nil)      // internal/platformclient — по HTTP

Что намеренно НЕ входит в контракт

Это самая содержательная часть комментария:

Genuinely host-local operations are deliberately NOT on API: Root, LoadStack, and EmptyStack return in-memory handles meaningless over the wire; StackUpdateFromTemplate re-renders the local template tree; and the CLI-host commands doctor and workspace open are not Platform methods at all.

То есть граница проведена не «всё, что умеет Platform», а «всё, что осмысленно на другом конце провода». Команды, требующие локального дерева шаблонов или локального окружения разработчика, остаются доступны только через конкретный *Platform.

Foreground-операции через контракт

StackUpForeground(ctx, services []string, build bool, stdout, stderr io.Writer) error

Приёмники вывода живут на стороне вызывающего, поэтому метод пересекает транспорт естественно: локальная реализация пишет туда вывод процесса, удалённая — стримит в них chunked-ответ оператора. Оговорка в комментарии: транспорт вправе слить stdout и stderr в один поток (HTTP отдаёт одно тело), поэтому полагаться на их раздельность нельзя.

Матрица поверхностей как проверяемый артефакт

docs/reference/surfaces.md классифицирует каждый экспортированный метод Platform (их 72) по трём поверхностям — CLI / REST / GraphQL — с колонкой «причина отсутствия». А internal/service/surface_matrix_test.go через рефлексию проверяет, что для каждого метода в таблице есть строка:

platformType := reflect.TypeOf((*Platform)(nil))
for i := 0; i < platformType.NumMethod(); i++ {
    name := platformType.Method(i).Name
    if !strings.Contains(doc, "| `"+name+"` |") {
        t.Fatalf("%s does not classify Platform.%s", docPath, name)
    }
}

Это policy-as-code: нельзя добавить операцию в домен, не приняв явное решение о её присутствии на каждой поверхности. Документация не может устареть — тест упадёт.


25. REST-поверхность

internal/operator/operator.go регистрирует 73 маршрута на http.ServeMux с method-pattern'ами Go 1.22+ ("POST /services/{name}/start"). Никакого роутера-зависимости.

Устройство хендлеров однообразно до монотонности — и это достоинство:

func (s *Server) sourcePull(w http.ResponseWriter, r *http.Request) {
    state, err := s.platform.SourcePull(r.Context(), r.PathValue("name"))
    if err != nil { writeError(w, err); return }
    writeJSON(w, http.StatusOK, state)
}

Декодирование → вызов platform → сериализация. Ни одной ветки бизнес-логики.

Группы маршрутов

Префикс Содержание
/healthz, /edge/verify без аутентификации (liveness и forward_auth)
/graphql POST (query/mutation/SSE) и GET (WebSocket upgrade)
/stack/* init, update, prepare, build, up, dev, down, destroy, logs, up/stream, dev/stream
/services/* CRUD, шаблонное обновление, up/start/stop/restart/destroy, logs, logs/stream, endpoint
/jobs/* list, run
/sources/* list, status, fetch, pull, push, diff
/workspaces/* CRUD, status, logs, git, push, sync-base, preflight, sources/{slot}/* (9 операций)
/gitops/topology производный агрегат
/templates, /templates/{ref...} интроспекция шаблонов
/secrets/* list, get, value (привилегированное чтение), set, delete
/files GET/PUT с source и path в query
/tokens/mint, /tokens/route выпуск токенов
/mcp дескриптор (заглушка)

Защитные меры на границе

  • Тело запроса ограничено 1 МиБ (maxRESTBodyBytes) через http.MaxBytesReader — включая generic-функцию decode[T], чтобы никакой POST не мог обойти лимит просто потому, что декодер обобщённый.
  • http.CrossOriginProtection оборачивает POST /graphql.
  • ReadHeaderTimeout: 5s на сервере.
  • Небезопасные операции — POST/PATCH/DELETE. Исключение задокументировано: GET /stack/up/stream и GET /stack/dev/stream имеют побочный эффект поднятия стека. Обоснование в комментарии: они соответствуют остальным стриминговым маршрутам и GET-only ридеру удалённого клиента, а сам эффект закрыт auth() и недостижим из браузера.

Стриминг

startStream фиксирует 200 OK и возвращает writer, делающий Flush после каждой записи. Последствие честно отмечено: раз статус уже отправлен, ошибка foreground-операции может быть сообщена только внутри потока (fmt.Fprintf(fw, "angee: %v\n", err)).

Завершение работы

ListenAndServe регистрирует SIGINT до старта слушателя (чтобы Ctrl-C в стартовом окне не убил процесс по умолчанию), затем ждёт по ctx.Done(), сигналу или ошибке сервера. Есть отдельный дренаж канала сигналов: если родительский контекст тоже отменяется по SIGINT, select мог проснуться на ctx.Done() одновременно с доставкой сигнала — дренаж делает решение о teardown детерминированным.

При teardown по SIGINT оператор опускает стек (StackDown) со свежим контекстом на 60 секунд. Свежий — намеренно, чтобы у остановки был собственный дедлайн, а не унаследованный уже истёкший.


26. GraphQL-поверхность: Hasura-диалект

Схема — internal/operator/schema.graphql, 993 строки SDL, из которых генерируется 29 557 строк Go через gqlgen.

Элемент Количество
Поля Query 35
Поля Mutation 42
Поля Subscription 11
type 59
input 36
enum 4
scalar 1 (JSON)

Почему именно Hasura-диалект

Ключевое решение: коллекции оператора выглядят так, будто их отдаёт Hasura.

services(where: services_bool_exp, order_by: [services_order_by!], limit: Int, offset: Int): [ServiceState!]!
services_by_pk(id: String!): ServiceState
services_aggregate(where: ..., ...): services_aggregate!
insert_services_one(object: services_insert_input!): ServiceState
update_services_by_pk(pk_columns: services_pk_columns_input!, _set: services_set_input!): ServiceState
delete_services_by_pk(id: String!): ServiceState

Причина прагматичная и названа прямо в hasura_bind.go: это позволяет подключить готовый фронтенд-провайдер @refinedev/hasura без адаптера. Шесть сущностей (services, jobs, sources, workspaces, templates, secrets) получают полный CRUD-подобный набор поверх того, что на самом деле является YAML-файлом и состоянием docker.

Границы диалекта задокументированы честно:

  • _not не поддерживается: «the @refinedev/hasura provider never emits it»;
  • _similar отображается в LIKE и корректен только для шаблонов value% / %value, которые refine генерирует для startswith/endswith; общий SQL-синтаксис SIMILAR TO не воспроизводится.

То есть эмуляция сделана ровно под фактического потребителя, и там, где она заканчивается, это записано.

Агрегаты и группировки

sources_aggregate даёт count, sum, avg, min, max по числовым полям (ahead, behind). Есть services_groups и sources_groups — авторская (не-Hasura) группировка с group_by, having, order_by.

Императивные мутации рядом с CRUD

Наряду с insert_services_one живут serviceUp, serviceStart, sourcePull, workspaceSyncBase, workspaceSourceRebaseContinue, mintConnectionToken — то есть чисто императивные операции, не выражаемые как CRUD. Схема не притворяется, что всё — таблицы.

Транспорты

Один gqlgen-сервер, три транспорта:

gqlServer.AddTransport(transport.SSE{})       // ДО POST — Accept-based dispatch
gqlServer.AddTransport(transport.POST{})
gqlServer.AddTransport(transport.GRAPHQL{})
gqlServer.AddTransport(transport.Websocket{...})

Порядок регистрации SSE перед POST не косметика — комментарий ссылается на gqlgen issue #3275: диспетчеризация идёт по Accept, и при обратном порядке text/event-stream не подхватывается.

Трансляция ошибок

formatGraphQLError использует тот же классификатор, что и REST (classifyServiceError), и переводит HTTP-статус в набор GraphQL-extensions:

Категория Extensions
404 kind, name
409 kind, name, reason
400 field, reason

Одна доменная ошибка — одно смысловое представление на обеих поверхностях.

Генерация как проверяемый инвариант

check-generated: generate
	git diff --exit-code -- internal/operator/gql docs/public/angee.graphql

make generate перегенерирует gqlgen-код и экспортирует SDL через cmd/gqlschema (который компилирует сгенерированный пакет, поэтому опубликованный артефакт физически не может разойтись со схемой, которую отдаёт оператор). CI падает при любом дрейфе. Аналогично check-schema для JSON Schema манифеста (cmd/schema + invopop/jsonschema).


27. Движок коллекций

internal/query — 219 строк, и его doc-comment сразу называет ограничение области:

Collections are small (manifest/runtime state, not a database), so the engine operates over a plain slice rather than translating to a query language.

Это трезвое решение: сервисов в стеке десятки, а не миллионы; трансляция в SQL была бы преждевременной.

type Args struct { Filter Filter; Sorting []Sort; Paging Paging }
type Filter struct { And []Filter; Or []Filter; Fields map[string]Comparison }
type FieldMap[T any] map[string]func(T) Value
func Apply[T any](items []T, a Args, fm FieldMap[T]) (page []T, total int)

Apply возвращает страницу и до-страничный total — ровно то, что refine читает как totalCount.

Детали, которые стоило бы заметить:

  • неизвестное поле в фильтре не матчит ничего (защитно), а query.Validate отвергает такой запрос раньше — превращая его в InvalidInputError и, соответственно, в 400;
  • размещение null абсолютно и не переворачивается флагом Desc: решение принимается до направленного сравнения, потому что NULLS_LAST при DESC должно оставаться NULLS_LAST;
  • сортировка стабильная (sort.SliceStable), поэтому многоключевые сортировки предсказуемы.

internal/queryfields содержит per-entity FieldMap и конверсию api.ListQuery ↔ query.Args. Его doc-comment называет причину существования: это единственный пакет, которому разрешено импортировать и api, и internal/query, что позволяет Platform, GraphQL-резолверам и удалённому клиенту пользоваться одними и теми же аксессорами без цикла импортов.

Мелкая, но говорящая деталь — strOrNull: пустая строка в omitempty-поле маппится в null-значение, чтобы фильтрация и сортировка трактовали "" как отсутствие, а не как минимальную строку.


28. Подписки: EventHub

Подписки — единственное намеренное расхождение поверхностей: у REST нет нативного pub/sub, поэтому live-каналы существуют только в GraphQL (через SSE или WebSocket).

type EventHub struct {
    platform     service.API
    pollInterval time.Duration           // 2s по умолчанию
    topology     *broker[*api.GitOpsTopologyResponse]
    snapshot     *broker[*model.StackSnapshot]
    workspaceBrokers map[string]*broker[*api.WorkspaceStatusResponse]
}

Модель: опрос + хеш + fan-out

Никаких файловых вотчеров. Тикер раз в 2 секунды читает снапшот, считает sha256 от его JSON и публикует только при изменении хеша. Три следствия:

  1. Подписчик не получает начальный снапшот. Это задокументировано и намеренно: клиент должен выпустить одноразовый query параллельно с открытием подписки.
  2. Ноль обращений к платформе при отсутствии подписчиков — каждый цикл начинается с if !broker.hasSubscribers() { continue }.
  3. Ошибка опроса не роняет подписку: reportPollError логирует первое появление каждой отличной ошибки и одну строку восстановления, чтобы неисправный стек не забивал stderr и при этом не молчал.

Агрегатный снапшот

onStackSnapshotChange — самая нагруженная подписка: buildSnapshot делает восемь чтений (status, services, jobs, sources, workspaces, templates, secrets, topology) и собирает их в один объект StackSnapshot. Первая же неудача прерывает сборку — «transient platform error skips the tick rather than publishing a partial snapshot». Смысл — заменить пер-вкладочный polling веб-консоли одним серверным опросом.

Пер-workspace-подписки создаются лениво, при первом подписчике, и живут до остановки хаба; последующие подписчики переиспользуют тот же тикер.

Логи мимо брокера

onServiceLogs / onWorkspaceLogs не проходят через брокер: они возвращают follow-канал runtime-backend'а напрямую. Отмена контекста рвёт docker compose logs --follow. Известное ограничение зафиксировано в todo.md: каждый подписчик поднимает собственный процесс follow; для одностекового оператора это принято как приемлемое на v1.

Жизненный цикл

Start идемпотентен через sync.Once. Stop безопасен при многократном и досрочном вызове; после него новые подписки получают предзакрытый канал. Server.Close вызывает eventHub.Stop, и это же делает defer внутри ListenAndServe — так что тесты, конструирующие сервер без запуска, могут погасить фоновые горутины явно.


29. Логи: три разных канала

Различение здесь не случайное — у трёх сценариев разные требования, и они не сведены к одному механизму.

Канал Транспорт Контракт
Снапшот GET /stack/logs, GraphQL stackLogs(limit:) ограниченное чтение; GraphQL использует StackLogsLimited как guardrail
Foreground-стрим GET /stack/up/stream, /stack/dev/stream chunked text/plain, flush после каждой записи; воспроизводит поведение локального CLI для --operator-клиентов
Per-service сокет GET /services/{name}/logs/stream (WebSocket) структурированные api.LogLine в JSON, с опциональным backlog ?tail=N

Шов для продакшн-бэкенда

type LogStreamer interface {
    StreamService(ctx, service string, tail int) (<-chan api.LogLine, error)
}

Две реализации: ephemeralStreamer (прокси живого follow платформы, без персистентности) и prodStreamerзаглушка, которая fail-closed возвращает ошибку. Выбор через --log-backend; неизвестное значение отвергается на старте, «so a misconfigured --log-backend fails fast instead of silently defaulting».

Это ещё один пример объявленного, но не подключённого шва — с той разницей, что здесь заглушка не молчит, а падает, делая шов наблюдаемым.

Аккуратность сокета

  • Поток открывается до upgrade, чтобы ошибка backend'а пришла клиенту чистым HTTP-статусом, а не закрытием сокета.
  • Reader-pump читает сообщения, которых браузер не шлёт: любая ошибка чтения означает, что клиент ушёл → отмена контекста → снос upstream-follow.
  • Определение живости только на стороне записи: read-deadline намеренно отсутствует (он убивал бы простаивающие, но здоровые сокеты), мёртвый peer выявляется первым неудачным keepalive-ping'ом.
  • tail зажимается в [0, 10000].

Самоописывающийся дескриптор

serviceEndpoint возвращает вместе с URL сервиса ещё и logStream:

{
  "url": "https://api.example.com",
  "logStream": {
    "url": "wss://operator.example.com/services/api/logs/stream",
    "target": "operator",
    "protocol": "ws",
    "token": "eyJ...",
    "expiresAt": "2026-08-05T12:00:00Z"
  }
}

Схема и хост берутся из того, как клиент дотянулся до оператора (для GraphQL это протаскивается через контекст, потому что gqlgen не отдаёт резолверам *http.Request). Токен минтуется на лету с аудиторией svc:<name>. Клиент открывает сокет без второго round-trip и без знания топологии.


30. Аутентификация и токены

Двухуровневая проверка

func (s *Server) auth(next http.Handler) http.Handler {
    if s.config.Token == "" { /* открыто — loopback dev */ }
    token, ok := parseBearer(r.Header.Get("Authorization"))
    claims, authed := s.authenticateBearer(token)
    if claims != nil { r = r.WithContext(withActorScope(r.Context(), *claims)) }
}

Два уровня: admin bearer (полный неограниченный доступ, server-to-server) и минтованный токен с aud=operator, чей актор и scope прикрепляются к контексту запроса.

Сравнение с admin bearer идёт через constantTimeEqual, который сначала хеширует оба значения — так постоянное время достигается независимо от длины токенов.

Пустой сконфигурированный токен оставляет оператора открытым — но только для loopback: NewServer отказывается стартовать на non-loopback-адресе без --token.

Иерархия ключа подписи

--jwt-secret → ANGEE_OPERATOR_JWT_SECRET → HKDF-подобный вывод из admin bearer → случайный на процесс

Вывод из bearer'а односторонний (HMAC-SHA256 с меткой angee-operator/jwt-derive/v1), поэтому утечка JWT-секрета не раскрывает admin-токен. Последний вариант (случайный на процесс) означает, что токены живут ровно столько, сколько живёт демон, и непрозрачны для кого-либо ещё.

При старте оператор печатает фингерпринт ключа (первые 4 байта sha256 в hex), чтобы можно было убедиться, что два процесса разделяют один секрет, не раскрывая сам секрет.

Две аудитории

Аудитория Выпуск Назначение
operator mintConnectionToken(actor, scope, ttl) доступ к API оператора
svc:<service> mintRouteToken(actor, service, ttl) открытие сокета одного маршрутизируемого сервиса через edge

TTL по умолчанию час, потолок сутки, ноль и отрицательные отвергаются. Verify требует HS256, нужного issuer'а, обязательного exp и непустой ожидаемой аудитории — «Fail closed: an empty expected audience means the caller failed to resolve which audience it requires… not "accept any audience"».

Scope сегодня совещательный: middleware кладёт его в контекст, но ни один путь его пока не проверяет. Это записано и в комментарии типа, и в todo.md как открытый пункт (per-actor RBAC для секретов требует отдельного дизайна).

Три точки входа, одна проверка

Три места принимают учётные данные, и все три сводятся к одним и тем же функциям:

  1. HTTP-middleware — Authorization: Bearer;
  2. graphqlWSInit — WebSocket-upgrade не может нести заголовок, поэтому токен едет в payload connection_init; та же parseBearer + authenticateBearer;
  3. authorizeServiceSocket / edgeVerify — токен извлекается edgeToken из четырёх возможных мест (см. §12), проверяется на аудиторию сервиса либо падает обратно на admin/operator-уровень.

Защита от cross-site WebSocket hijacking

CrossOriginProtection не гейтит GET-upgrade (GET считается безопасным), поэтому эту роль берёт CheckOrigin:

func originAllowed(origin string, allowed []string) bool {
    if origin == "" { return true }              // не-браузерный клиент не может подделать Origin
    u, err := url.Parse(origin)
    if err != nil || u.Host == "" { return false }
    if isLoopback(strings.ToLower(u.Hostname())) { return true }
    for _, a := range allowed { if strings.EqualFold(a, origin) { return true } }
    return false
}

Fail-closed, loopback всегда разрешён, hostname приводится к нижнему регистру перед проверкой (http://LOCALHOST:5173 должен распознаваться).


31. Файловый объектный слой

Небольшая, но показательная подсистема: возможность читать и писать файлы внутри объявленного источника через API оператора (нужна агентам и веб-консоли).

Три уровня:

  1. internal/store — реестр backend'ов key → Blob с capability-интерфейсами:

    type Store interface { Get; Set }
    type Lister interface { List }
    type Deleter interface { Delete }
    type Versioned interface { GetIf; SetIf }   // CAS через etag

    Backend'ы саморегистрируются в init(); store.Open(kind, cfg)единственный путь конструирования.

  2. internal/files — объектный слой: UTF-8-валидация, лимит 1 МиБ (совпадает с REST-лимитом тела), etag-CAS, sentinel-ошибки (ErrNotFound, ErrEtagMismatch, ErrNotContained, ErrNotText, ErrTooLarge, ErrNotVersioned). Doc-comment подчёркивает: «It carries no YAML or other domain knowledge — it deals in raw bytes only».

  3. internal/service.FileRead/FileWrite — резолв имени источника в каталог и трансляция sentinel-ошибок в типизированные доменные.

Конкурентность localfs

Тонкая деталь: мьютекс, сериализующий read-modify-write в SetIf, объявлен на уровне пакета и ключуется каноническим путём корня, а не на экземпляре:

localFS instances are constructed fresh per request (store.Open), so the mutex must live at package scope keyed by the canonical root path; an instance-level lock would not serialize two concurrent operator requests that each opened their own localFS over the same source dir.

Вместе с атомарной записью temp+rename это закрывает и гонку потерянного обновления, и окно рваного чтения. Корень канонизируется через EvalSymlinks при конструировании, чтобы последующие проверки вложенности сравнивали сравнимое (/var/private/var на macOS).

Объявленный шов

В manifest.go оставлен комментарий-заглушка:

// Seam: a future SecretsBackend-shaped FilesBackend block would select a
// non-localfs files store, opened via store.Open. Files default to the
// zero-config localfs backend rooted at the resolved source dir, so no
// manifest config is needed today.

Опять тот же паттерн: место расширения названо, но не построено.


32. CLI

internal/cli — 73 объявленные команды в Cobra-дереве. Корень принимает три persistent-флага: --root, --operator (дефолт из ANGEE_OPERATOR_URL), --json.

Выбор транспорта

Каждая команда начинается с получения service.API:

platform, err := localPlatform(root, operatorURL)

Если --operator не задан — это *service.Platform. Если задан — *platformclient.RemoteClient. Дальше код команды идентичен. RemoteClient.Ping (короткий GET /healthz с малым таймаутом) позволяет откатиться на локальное исполнение, когда URL сконфигурирован, но демон лежит.

Интерактивность

Отдельная категория — команды, задающие вопросы: angee init резолвит входы шаблона интерактивно (через charmbracelet/huh, приходящий транзитивно с copier-go), если не передан --yes. Интерактивность живёт только в CLI; домен её не знает.

angee doctor

Диагностическая команда, проверяющая предпосылки локальной разработки и возвращающая структурированный отчёт (ok/warn/error + подсказка). Проверки:

  • наличие внешних инструментов (docker, git, process-compose);
  • корректность манифеста;
  • существование local-источников на диске;
  • занятость объявленных портов;
  • корректность пулов портов;
  • наличие .angee в .gitignore;
  • доступность шаблонов.

Ненулевой код возврата при наличии ошибок делает doctor пригодным для CI. Это единственная команда, целиком живущая в CLI и не имеющая метода в Platform — потому что она диагностирует окружение разработчика, а не стек.


33. Модель ошибок

Домен определяет три типизированные ошибки:

type NotFoundError    struct{ Kind, Name string }
type ConflictError    struct{ Kind, Name, Reason string }
type InvalidInputError struct{ Field, Reason string }

Их путь через все поверхности:

service.*Error
   ├─ classifyServiceError → HTTP 404 / 409 / 400 + структурированное тело (REST)
   ├─ formatGraphQLError   → gqlerror + extensions{kind,name,reason,field} (GraphQL)
   └─ platformclient       → RemoteNotFound / RemoteConflict / RemoteInvalidInput

Замкнутость контура — существенное свойство: удалённый клиент восстанавливает категорию ошибки из HTTP-статуса, поэтому команда CLI, работающая через --operator, ведёт себя так же, как локальная, включая обработку «уже существует» и «не объявлено».

Отдельный мостик — invalidQueryError, превращающий *query.FieldError (неизвестное поле фильтра/сортировки) в InvalidInputError. То есть ошибка движка коллекций автоматически становится 400, а не 500.


34. Сквозные жизненные циклы

34.1 angee up

LoadStack (strict parse + validate)
→ bootstrapOpenBao (если backend openbao и он объявлен сервисом)
→ StackPrepare:
     fslock(<root>/run/operator.lock)
     materializePersistPaths
     materializeReferencedSources     (клон/проверка источников, БЕЗ fetch)
     secrets.FromManifest + ResolveDeclarations
     Compile → CompiledStack
     writeRuntimeEnv                  (run/secrets.env только для openbao)
     writeCompiled                    (docker-compose.yaml / process-compose.yaml)
→ selectRuntimeServices(container)
→ composeBackend.Up(Target{Root, Services, Build, EnvFile})

34.2 angee workspace create <name> --template dev-pr

resolveTemplate → ValidateMetadata(kind=workspace)
→ manifest.Ensure(stack, _angee.ensure)     — пулы портов и прочие инварианты
→ workspaceInputs (defaults + generated + переданные)
→ workspaceName (instance_naming.pattern)
→ allocateWorkspacePorts (owner=workspace/<name>, проба занятости хоста)
→ materializeWorkspaceSources               — worktree на ветке workspace'а
→ buildWorkspaceRenderPlan                  — слой шаблона + слои chain
→ PrepareReconcile(ReconcileCreate)         — рендер в scratch, расчёт diff'а
→ ApplyFiles                                — запись + журнал отката
→ applyRenderedDocuments                    — внутренние angee.yaml
→ materializePersistPaths
→ verify всех capability
→ parentTx.Save(stack)                      — с проверкой «файл не менялся»
→ prepared.SaveState                        — фиксация render-state
→ committed

Любой шаг после 9-го при ошибке разматывает всю лестницу откатов.

34.3 angee stack update --template

readGuardedStackDocument(angee.yaml)
→ locateStackAnswers                        — .copier-answers.yml здесь или у родителя
→ workspacePortInputs                       — перекрытие портов для внутреннего стека
→ resolveTemplate + TemplateInputs
→ buildStackRenderPlan (шаблон + chain)
→ PrepareReconcile(ReconcileUpdate, DryRun?, Overwrite?)
→ конфликты → ConflictError (если не dry-run)
→ структурное слияние манифеста:
     сохранить operator / workspaces / port_leases / значения ports
     обновить секции шаблонного происхождения
     сохранить пользовательские ключи
→ Ensure заново
→ применить документы + сгенерированные runtime-артефакты одной транзакцией

34.4 Открытие лог-сокета сервиса из браузера

Host (server-side, с admin bearer)
  → POST /tokens/mint {actor, scope, ttl}     → operator-токен
Браузер (с operator-токеном)
  → GraphQL serviceEndpoint(name)             → {url, logStream{url, token(aud=svc:name)}}
  → WebSocket на logStream.url с ?token=      → authorizeServiceSocket → Verify(aud=svc:name)
  → живой поток api.LogLine

Admin bearer никогда не покидает серверную сторону. Браузер получает два коротких токена: один для API, один — для одного конкретного сокета.


35. Границы безопасности

Сильные границы

Граница Механизм
Файловая система вне корня os.Root-capability, verify-after, allow-list симлинк-родителей
Инъекция в Caddyfile проверка метасимволов в ingress.* и route.* при валидации манифеста
Инъекция в shell shellQuote для команд локальных процессов
Утечка секретов в git секреты уходят в compose ссылками на env-переменные, не значениями
Утечка admin bearer в браузер двухуровневые токены, minted-токены с ограниченным TTL и аудиторией
Cross-site WS hijacking CheckOrigin с fail-closed allow-list
CSRF на GraphQL POST http.CrossOriginProtection
DoS через тело запроса 1 МиБ на REST и GraphQL
DoS через лог-строку 1 МиБ на строку сканера, [truncated]-маркер
DoS через withCommits clamp сверху
Timing-атака на bearer хеширование + subtle.ConstantTimeCompare
Неуникальность compose-проекта хеш абсолютного корня в имени проекта
Конкурентная запись манифеста flock + оптимистическая проверка байтов
Конкурентная запись файла package-scope мьютекс по каноническому корню + temp/rename

Trust boundaries, которые остаются

  1. Шаблон исполняется с правами оператора. Copier-шаблон — это код (Jinja + произвольная структура файлов). Модель доверия — «шаблоны доверенные», как и addon'ы в первой части.
  2. scope в токене не проверяется. Любой валидный minted-токен эквивалентен admin bearer'у по правам. Это явно записано в коде и в todo.md; сегодня разделение существует только между «есть токен» и «нет токена».
  3. Секреты читаются целиком. secretValue — привилегированное чтение, вынесенное в отдельный endpoint именно для того, чтобы быть заметным в аудите и code review, но ограничить его отдельному актору сейчас нечем.
  4. Оператор монтирует docker-сокет. Edge-контейнер получает его в read-only, но сам оператор в контейнерном развёртывании нуждается в доступе к демону — это по построению root-эквивалентный доступ к хосту.
  5. local-источники ведут наружу корня. Dev-overlay намеренно указывает на код за пределами ANGEE_ROOT; capability-слой верифицирует цель, но не ограничивает её.

36. Детерминированность и консистентность

Детерминированность компиляции

  • Все обходы map'ов через sortedKeys — байт-в-байт воспроизводимый выход.
  • Дефолты применяются при чтении, а не при записи — неизменённый манифест пересохраняется идентично.
  • Имя compose-проекта — чистая функция от (name, abs(root)).
  • Индексы Caddy-директив назначаются по алфавиту, а не по порядку обхода.
  • _angee.ensure — fail-on-different, а не last-write-wins: две конфликтующие декларации ломают операцию, а не выигрывают по очереди.

Консистентность записи

  • Все многофайловые операции идут через PreparedReconcile с журналом и откатом.
  • Родительский манифест защищён оптимистической проверкой байтов.
  • SaveState — последний шаг; провал отменяет и запись манифеста.
  • Откат восстанавливает и топологию каталогов (pruneEmptyParents, RemoveMissingParents).

Где консистентности намеренно нет

  • StackStatus не консистентен: это наблюдение через два независимых супервизора, ошибки проглатываются, отсутствующие сервисы получают declared. Это read-only-представление, а не источник истины.
  • ServiceCreate не атомарен целиком: манифест записывается под локом, перекомпиляция и старт — после освобождения. Промежуточное состояние возможно, и способ восстановления назван в комментарии.
  • GitOpsTopology пересчитывается на каждый запрос: кэша нет, «the view recomputes from the underlying state on every read».

37. Тестовая архитектура и guardrails

Профиль

Пакет Тестов
internal/service 129
internal/operator 72
internal/copierx 42
internal/cli 38
internal/manifest 24
internal/operator/gql 24
internal/query 16
internal/store 12
прочие ≈ 70
Всего 427

Приёмы

  • Runner-инъекция: оба runtime-backend'а тестируются подстановкой фейкового Runner, проверяющего точную командную строку, — без docker и process-compose.
  • t.TempDir() + реальная файловая система: реконсиляция и capability-слой тестируются на настоящих файлах, включая симлинки и подмену типов записей. Мокать здесь было бы бессмысленно — тестируется именно поведение ФС.
  • Testdata-шаблоны: internal/service/testdata/service-templates/claude-code/ — полноценный Copier-шаблон, используемый как фикстура.
  • Race-детектор обязателен: make test = go test -v -race ./..., и CI гоняет матрицу linux + macos.

Исполняемые инварианты

Четыре проверки превращают документацию и генерацию в контракты:

Проверка Что доказывает
surface_matrix_test.go каждый экспортированный метод Platform классифицирован в docs/reference/surfaces.md
make check-generated gqlgen-код и опубликованный SDL не разошлись со схемой
make check-schema опубликованная JSON Schema манифеста соответствует Go-структурам
go mod tidy is clean (CI) нет дрейфа зависимостей

Плюс golangci-lint v2 с включённым gosec и gocritic, и codeql.yml. Конфигурация errcheck заслуживает упоминания как образец: каждое исключение снабжено обоснованием прямо в YAML («writing to stdout/stderr in CLI — nothing useful to do on error»).


38. Сильные стороны архитектуры

38.1 Один контракт, три транспорта, доказуемо

service.API + матрица поверхностей + рефлексивный тест — это не «мы стараемся держать паритет», а механическая невозможность его потерять незаметно. При этом контракт очерчен по смыслу («что осмысленно по проводу»), а не механически.

38.2 Домен без транспорта, транспорт без домена

Хендлеры REST однострочные, GraphQL-резолверы — тоже. Вся сложность в internal/service, и она там вся. Это позволяет читать бизнес-логику, не отвлекаясь на HTTP, и добавлять поверхности почти без риска.

38.3 Файловые операции как транзакции

Реконсиляция с fingerprint'ами, три-way-разрешение, dry-run, откат и capability-safe пути — это уровень строгости, который в CLI-инструментах встречается редко. И он оправдан: инструмент по построению пишет в каталоги, которые редактируют другие.

38.4 Комментарии, объясняющие «почему»

Кодовая база необычно богата комментариями, фиксирующими причину, а не действие: почему SSE регистрируется до POST, почему down в process-compose нельзя давать -f, почему mutex на уровне пакета, почему свежий контекст в откате, почему дефолты при чтении. Значительная часть этого документа — пересказ этих комментариев. Для проекта, который ведут вместе люди и агенты, это самый ценный вид документации: он находится там, где принимается решение.

38.5 Швы объявляются заранее и явно

store.Register, LogStreamer, edge.Backend, FilesBackend-комментарий, регистрация секретов в store-реестре — точки расширения названы до того, как понадобились, и явно помечены как неподключённые. Заглушки fail-closed, а не молча дефолтятся.

38.6 Терминологическая дисциплина

Три «обновления» названы тремя разными именами именно потому, что означают три разные вещи. pull / sync-base / slot pull не взаимозаменяемы, и API это отражает. Аналогично «workspace никогда не запускает сервисы» — граница, которую легко было бы размыть ради удобства, но она удержана.

38.7 Правильный выбор границы «своё / чужое»

Гибридный git-клиент (go-git для чтения, CLI для сети и записи), Hasura-диалект под конкретного фронтенд-потребителя, шелл в docker вместо API — везде выбрано то, что даёт корректность в реальном окружении пользователя, а не то, что элегантнее.


39. Ограничения и архитектурная цена

39.1 Один оператор — один корень

Оператор по построению одностековый. Мультитенантность достигается запуском нескольких процессов на разных портах. Для сценария «пул эфемерных workspace'ов с автоматическим поднятием» этого мало; в docs/proposals/ лежат черновики (ephemeral-workspace-pool.md, local-platform-instance.md, global-source-registry.md), но они не реализованы.

39.2 Подписки — это опрос

2-секундный тикер + sha256 всего снапшота. Для стека из десятка сервисов это дёшево; для крупного — нет. Миграция на fsnotify отложена, причём с политической мотивировкой, записанной в todo.md (нерешённая ситуация с governance проекта fsnotify).

39.3 Логи не персистентны

Продакшн-backend логов — заглушка. Всё, что доступно, — прокси живого --follow от супервизора. Уйдёт клиент — уйдёт и процесс follow. Каждый подписчик поднимает собственный процесс; шаринг канала — открытый пункт.

39.4 Scope токенов не проверяется

Токен либо есть, либо его нет. mintConnectionToken(actor, scope, ttl) принимает scope, кладёт его в claim и в контекст — и на этом всё. То есть API выглядит так, будто RBAC есть, а его нет. Это самый заметный разрыв между формой и содержанием в текущей кодовой базе, и он честно помечен в трёх местах.

39.5 TTL workspace'ов не собирается

Поле есть, expires-метка вычисляется, сборщика нет.

39.6 Compose-модель самописная

internal/runtime/compose/doc.go — 33 строки собственных структур вместо compose-spec/compose-go. Пока не понадобилось поле, которого нет, это дёшево; в момент, когда понадобится, миграция затронет компилятор. План замены записан и намеренно отложен на третью очередь.

39.7 OpenBao-клиент рукописный

120 строк собственного HTTP-клиента KV v2, у которого List вообще не реализован. План замены на официальную библиотеку — первая по приоритету миграция в .agents/plans/LATEST.md, и мотивация названа как «staleness liability»: расхождение по auth, retry, TLS, namespaces и обновлению токенов.

39.8 MCP — заглушка

GET /mcp возвращает статический дескриптор. Полноценный MCP-сервер (через modelcontextprotocol/go-sdk или mark3labs/mcp-go) — вторая запланированная миграция. Учитывая позиционирование «agent-native», это заметный пробел: агенты сегодня разговаривают с оператором по REST/GraphQL, а не по MCP.

39.9 Половина не-тестового кода сгенерирована

29 557 из 55 799 строк — gqlgen. Это не проблема сама по себе (артефакт проверяется на дрейф в CI), но искажает любую метрику по размеру и требует помнить, что реальная поверхность правки — вдвое меньше.

39.10 Остаточная сложность оркестрации

WorkspaceCreate — двести строк с пятью лестницами defer-откатов и тремя видами capability-верификации. Код корректен и хорошо прокомментирован, но порог входа высок, и добавление шага требует понимания всей лестницы. Это прямая цена за транзакционность §17–18; альтернативы (не откатывать, откатывать грубо) хуже, но цена реальна.

39.11 Удалённое разрешение шаблонов только для GitHub

parseGitHubTemplateRef отвергает любой хост, кроме github.com. Для GitLab, Gitea или self-hosted — только локальный путь или предварительный клон.


40. Важнейшие инварианты системы

  1. Один оператор обслуживает ровно один ANGEE_ROOT.
  2. Единственный источник истины — angee.yaml; всё остальное производно.
  3. Неизвестный ключ манифеста — ошибка загрузки, а не игнор.
  4. Дефолты применяются при чтении; неизменённый манифест пересохраняется побайтово идентично.
  5. Компиляция детерминирована: тот же вход → тот же байт.
  6. Имя compose-проекта уникально по абсолютному корню, а не по имени стека.
  7. Секрет никогда не попадает в сгенерированный compose-файл — только ссылка на env-переменную.
  8. Workspace — файловый примитив; он никогда не запускает сервисы.
  9. Запуск чего бы то ни было — всегда операция над Stack.
  10. Приложение — это Source, а не Stack.
  11. Всякая запись на диск идёт через rooted-capability с проверкой идентичности после операции.
  12. Симлинк в родителях пути запрещён, кроме явно объявленного allow-list'а.
  13. Многофайловая операция транзакционна: либо применилась целиком, либо откатилась целиком.
  14. Откат не удаляет то, чего операция не создавала.
  15. Очистка выполняется на контексте, независимом от отменённого запроса.
  16. Родительский манифест сохраняется только если его байты не изменились с момента открытия транзакции.
  17. Блокировка корня нерекурсивна; вложенные вызовы обязаны освободить её заранее.
  18. Локальная правка отрендеренного файла не теряется без явного --overwrite.
  19. _angee.ensure — fail-on-different, а не last-write-wins.
  20. Аренда порта идемпотентна по владельцу.
  21. Порт проверяется на занятость хоста до выдачи.
  22. Read-only git-запросы идут через go-git, сетевые и пишущие — через git CLI.
  23. Каждая экспортированная операция домена классифицирована по трём поверхностям, и это проверяется тестом.
  24. Ни один адаптер не содержит бизнес-логики.
  25. Одна доменная ошибка имеет одно смысловое представление на всех поверхностях, включая удалённый клиент.
  26. Неизвестное поле фильтра — 400, а не 500.
  27. Подписка не отдаёт начальный снапшот; клиент обязан запросить его отдельно.
  28. При отсутствии подписчиков платформа не опрашивается.
  29. Токен всегда имеет ровно одну аудиторию; пустая ожидаемая аудитория — отказ.
  30. Admin bearer не покидает серверную сторону; браузер получает только короткоживущие minted-токены.
  31. Non-loopback-бинд без токена не стартует.
  32. Origin WebSocket проверяется fail-closed.
  33. Сгенерированные артефакты (gqlgen-код, SDL, JSON Schema) не могут разойтись с исходником — CI падает.
  34. Оператор не знает ничего о фреймворке, который он запускает.

41. Навигация по исходникам

Схема и компиляция

Файл Зачем читать
internal/manifest/manifest.go схема angee.yaml, валидация, канонический re-save
internal/manifest/ensure.go шаблонные инварианты, fail-on-different
internal/service/platform.go Compile, StackPrepare, генерация runtime-артефактов
internal/service/compose_project.go идентичность compose-проекта
internal/substitute/substitute.go грамматика подстановок, двойная семантика секретов
internal/mount/mount.go разбор mount-URI, контейнерный и локальный резолв
internal/stackroot/stackroot.go разрешение ANGEE_ROOT

Шаблоны, реконсиляция, capability-FS

Файл Зачем читать
internal/copierx/copierx.go _angee-метаданные, типы входов, type: path
internal/copierx/reconcile.go render-state, fingerprint'ы, 3-way, apply/rollback, TrustedRoot/GuardedPath
internal/service/template_plan.go планы рендера, parentStackTransaction, лестницы откатов
internal/service/templates.go локальное и удалённое разрешение шаблонов
internal/service/stack_update.go структурное слияние манифеста при re-render

Домен

Файл Зачем читать
internal/service/api.go контракт control plane и его границы
internal/service/workspaces.go полный жизненный цикл workspace'а
internal/service/sources.go материализация источников, staging
internal/service/gitops.go, gitops_merge.go топология и конвергенция слотов
internal/service/service_create.go service-шаблоны, автодекларация секретов
internal/service/jobs.go, job_output.go выполнение заданий и живой вывод
internal/service/errors.go типизированные доменные ошибки

Runtime

Файл Зачем читать
internal/runtime/backend.go интерфейс backend'а
internal/runtime/stream.go безопасный стрим вывода процесса
internal/runtime/compose/backend.go docker compose, Runner-шов, ограниченное чтение
internal/runtime/proccompose/backend.go process-compose, control-порт, автоустановка
internal/runtime/edge/caddy.go генерация Caddy-labels, host/path-режимы

Control plane

Файл Зачем читать
internal/operator/operator.go маршруты, двухуровневая auth, lifecycle
internal/operator/graphql.go транспорты gqlgen, WS-init, трансляция ошибок
internal/operator/tokens.go минтинг и верификация JWT, иерархия ключа
internal/operator/edge.go forward_auth-верификация и извлечение токена
internal/operator/logstream.go per-service лог-сокет, LogStreamer-шов
internal/operator/schema.graphql SDL: Hasura-диалект + императивные мутации
internal/operator/gql/hasura_bind.go биндинг диалекта на движок коллекций
internal/operator/gql/events.go EventHub, опрос, хеширование, fan-out
internal/platformclient/client.go реализация контракта по HTTP

Инфраструктурные пакеты

Файл Зачем читать
internal/query/query.go движок filter/sort/page
internal/queryfields/fields.go per-entity FieldMap, разрыв цикла импортов
internal/secrets/backend.go, resolve.go резолв деклараций
internal/service/openbao.go самозагрузка хранилища
internal/ports/pool.go пулы и аренды
internal/git/git.go гибридный git-клиент и worktree-хозяйство
internal/store/registry.go, localfs.go реестр backend'ов, CAS, конкурентность
internal/fslock/fslock.go блокировка корня

Enforcement

Файл Зачем читать
AGENTS.md конституция репозитория и правила владения
docs/reference/surfaces.md матрица поверхностей
internal/service/surface_matrix_test.go тест, делающий матрицу обязательной
Makefile check-generated, check-schema
.golangci.yml линтеры и обоснованные исключения
.github/workflows/ci.yml стадии CI, матрица ОС, race-детектор

42. Итоговая формула

angee-operator состоит из четырёх машин — и они намеренно зеркальны четырём машинам angee-django из первой части, только на уровень ниже:

  1. Машина манифеста — строгая схема, трёхуровневая валидация, детерминированный канонический re-save, Ensure как язык шаблонных инвариантов.
  2. Машина материализации — Copier-рендер со stateful-реконсиляцией, capability-safe файловые примитивы, транзакционный apply с откатом, git-worktree'ы, пулы портов, персистентные пути.
  3. Машина исполнения — компиляция в docker-compose и process-compose, ingress-вклад, резолв секретов, супервизоры, стримы логов.
  4. Машина control plane — один контракт service.API, две его реализации, три изоморфные поверхности, подписки поверх хешированного опроса, двухуровневая модель токенов.

Первая часть заканчивалась формулой:

Angee — это детерминированный compiler вертикальных Django/React addon'ов в единое permissioned, metadata-driven приложение для людей, сервисов и агентов.

Вторая половина системы дополняет её симметрично:

Angee Operator — это детерминированный компилятор одного манифеста в живое окружение, с транзакционной материализацией файлов и единой операционной поверхностью для людей, сервисов и агентов.

А вместе они образуют то, что документация называет self-managed стеком: одна и та же декларация описывает и то, где приложение живёт, и то, из чего оно состоит; и обе половины компилируются заранее в обычный, проверяемый runtime, а не патчат его на ходу.

Именно это и есть та единственная общая идея, ради которой стоило разбирать оба репозитория подряд. angee-django компилирует addon'ы в Django-приложение. angee-operator компилирует манифест в окружение. Ни один из них не «регистрирует» ничего в рантайме — оба превращают декларации в артефакты, которые можно прочитать, сравнить, положить в git и откатить.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment