# Нативные сторонние плагины — SDK 0.2.0

Автор описывает публичные поля и обработчики на Python. Pulse показывает коллекции,
карточку записи, форму команды, подтверждение и сохранённый результат. Отдельный
React-компонент по ID вашего плагина не нужен. Права покупки/подписки плагина остаются
в существующем маркетплейсе; встроенные RentSteam и другие плагины не изменяются.

## Попробовать локально

Установите SDK 0.2.0 с включённым preview. В окружении автора нужны только
Python 3.10+ и wheel SDK:

```sh
pulse-plugin init my_catalog --template native-catalog
pulse-plugin verify my_catalog
pulse-plugin dev my_catalog
```

Откройте адрес, напечатанный `dev`: по умолчанию `http://127.0.0.1:8787/`.
Preview использует `MarketplaceNativeWorkspace` — тот же React-renderer коллекций
и команд, что страница установки. JavaScript и CSS включены в wheel; Node, исходники
Pulse и отдельно запущенный frontend в окружении автора не нужны.

Для сборки локального candidate из checkout разработчиком платформы:

```sh
"Pulse backend/.venv/bin/python" tools/build_native_sdk_candidate.py --out /tmp/my-sdk-candidate
```

Сборщик использует существующие зависимости и lockfile frontend, затем добавляет
renderer и шаблон в wheel во временном каталоге. Это тестовая сборка без публикации.
Для разработки самого frontend остаётся `--preview-origin http://127.0.0.1:3105`
и маршрут `/sdk-preview?port=8787`. Другой порт simulator задаётся через `--port`.
Simulator слушает только `127.0.0.1`, проверяет Origin и Host. Данные тестовые и
исчезают после остановки самого `dev`. Python и схема подхватываются автоматически.

Команды `schema`, `verify`, `dev` исполняют ваш `app.py`. Импортируйте только свой
проверенный проект. Сервер License не импортирует Python автора для чтения схемы.

## Изменение кода и тестовых настроек

`dev` следит за `.py` внутри проекта, `funpay-pulse.plugin.json`, `legacy-preview.json`
и `native-fixtures.json`. Изменение импортированного Python-файла тоже перезапускает
обработчики. Виртуальные окружения, скрытые каталоги и кэши исключены. Контракт
генерируется в памяти; `schema DIR --write` нужен отдельно для сохранения manifest.

Страница обновляется автоматически и использует тот же renderer. В «Тестовых
настройках» можно изменить config, сохранить его и сразу увидеть новый расчёт данных.
Проверяются публичность значений, типы, обязательные поля и ограничения схемы.
Конфликт сохранения с текущим запросом или другим сохранением возвращает 409;
черновик остаётся в форме. Настройки не записываются в manifest и не отправляются
в установленный плагин.

При reload сохраняются собственное fixture-storage, config, ID и результаты операций:

- Завершённая команда не запускается снова. Точный повтор запроса возвращает её ID.
- Запрос, который ещё не начал выполняться, становится `expired`.
- Прерванная команда получает `unknown`; для неё доступна отдельная сверка, если
  автор сохранил ту же декларацию либо явно объявил совместимость с исходной версией.
  Обычный обработчик не повторяется.
- Открытая форма команды сохраняет исходную ревизию preview. После смены кода или
  настроек для нового запуска нужно закрыть эту форму и открыть действие заново.
  Уже отправленный запрос продолжает проверяться по прежнему ID.
- Черновик настроек сохраняется. Если прежний config несовместим с новой схемой,
  обработка приостанавливается до исправления. Подстановка defaults меняет только
  черновик; применение требует сохранения.

Начальные storage-fixtures загружаются один раз за сессию `dev`; их редактирование
не перезаписывает накопленные данные. Для чистого набора остановите и снова запустите
`dev`. Собственные данные плагина не мигрируются автоматически: результат каждого
нового запроса коллекции проверяется по текущей модели и при несовместимости показывает
ошибку вместо старых строк. Ошибка загрузки Python видна в preview; после исправления
файла процесс запускается снова.

Simulator сохраняет состояние в родительском процессе, а код плагина работает в
заменяемом дочернем процессе с локальным fixture-клиентом. Это не sandbox для
недоверенного Python и не проверка настоящих поставщиков. Рабочий цикл моделирует
`process_once_async`; полная проверка startup/shutdown hooks требует отдельного запуска.

## Описание плагина

### Выбор аккаунта, лота и фильтры

Объявите ресурс коллекции и dataclass фильтров:

```python
@dataclass
class QueueFilters:
    waiting_only: bool = False

@plugin.collection("queue", model=QueueRow, title="Очередь",
                   resource="lot", filters=QueueFilters)
def queue(ctx, query):
    account_id = ctx.resource.account_id
    lot_id = ctx.resource.lot_id
    waiting_only = query.filters["waiting_only"]
    # Верните CollectionPage из собственных данных этого аккаунта и лота.
```

`resource="account"` показывает выбор аккаунта, `resource="lot"` — аккаунта и лота.
Команда с `target="queue"` наследует ресурс коллекции. Самостоятельная команда
может объявить свой `resource`. `ctx.resource` содержит `account_id`, `lot_id`,
подписи, `resource_id` или `delivery_id`, а также `expires_at`. Без объявления
ресурса он равен `None`. Фильтры доступны в `query.filters`: SDK проверяет типы
и подставляет defaults. До восьми публичных scalar-полей; неизвестные ключи
отвергаются. Применение и сброс фильтров возвращают интерфейс на первую страницу.

Совместимый Worker раз в минуту передаёт полный снимок сохранённых в Pulse аккаунтов
и лотов. Аккаунты без заказов и без лотов тоже доступны. Это локальный каталог Pulse;
обновление самих лотов с FunPay выполняется обычным способом в разделе аккаунта.
Снимок содержит только ID и подписи, без ключей, прокси и параметров авторизации.
Подпись VPS, принадлежность лицензии и доступ к плагинам проверяются сервером.
До 10 000 ресурсов и 2 МиБ на снимок; превышение отклоняет весь снимок.

Каталог имеет поиск и страницы по 50 ресурсов. Сначала выбирается аккаунт, затем
загружаются его лоты. Снимок актуален 5 минут; устаревший список виден, но выбрать
ресурс или начать новую операцию по нему нельзя. Удалённый аккаунт или лот также
блокируется перед выполнением. При изменении содержимого каталога старый курсор
отклоняется: нужно обновить поиск. Неизменившийся снимок сохраняет курсор.

Для старого Worker остаются ресурсы из событий этой установки за последние 24 часа;
интерфейс явно показывает ограниченное покрытие. Локальный simulator берёт полный
синтетический каталог из `inventory` в `native-fixtures.json`; прежний массив
`resources` с delivery-ссылками также поддерживается.

Запрос передаёт только `{kind, resource_id}` либо `{kind, delivery_id}`. Сервер сам
восстанавливает аккаунт и лот, повторно проверяет доступ перед claim и сохраняет
контекст в истории. Проверка результата наследует исходный ресурс; заменить его нельзя.
Автор ограничивает собственную выборку и побочный эффект этим контекстом, как в
`native_ui.py` примера Order Queue. Resource ref не выдаёт дополнительных прав Broker
на сообщения, возвраты или изменение лотов: для этих вызовов сохраняются исходная
delivery, scopes, цель и срок доступа. Если ресурс отозван, `unknown` остаётся неизвестным.

### Сортировка

```python
from funpay_pulse_sdk import CollectionSort

@plugin.collection("catalog", model=CatalogRow, title="Каталог",
                   sortable=["id", "title", "stock"],
                   default_sort=CollectionSort("id", "asc"))
def catalog(ctx, query):
    rows = load_and_filter_rows(query.search, query.filters)
    if query.sort:
        rows.sort(key=lambda row: (getattr(row, query.sort.field), row.id),
                  reverse=query.sort.direction == "desc")
    return paginate(rows, query.cursor, query.limit)
```

`load_and_filter_rows` и `paginate` здесь обозначают функции вашего плагина.
Полный рабочий вариант находится в шаблоне `native-catalog`. Объявляется до восьми
полей модели, направление — `asc` или `desc`. SDK отвергает неизвестные поля и
направления. Сортируйте всю выборку до пагинации и используйте стабильный ID при
равенстве значений. Изменение сортировки в интерфейсе сбрасывает курсор.

### Модели и обработчики

Функция `create_plugin(client)` возвращает `NativePlugin(client, settings=Settings)`.
Settings, вход/выход команды и публичная запись — стандартные `@dataclass`.
Поддерживаются `str`, `int`, `float`, `bool` и однородные `Literal`.
`field(metadata={"title": ..., "minimum": ..., "maximum": ..., "maxLength": ...})`
задаёт подписи и ограничения. Буквальные default используются формой.

`@plugin.collection("catalog", model=CatalogRow, title="Каталог", columns=[...])`
регистрирует обработчик `(ctx, query)`. `query.search`, `query.cursor`, `query.limit`
передаются в вашу бизнес-логику; ответ — `CollectionPage(items, next_cursor, total)`.
В записи обязательны непустые string `id` и `revision`. Порядок коллекций и `columns`
задаёт минимальный layout. `title_field="order_id"` выбирает заголовок записи вместо
технического ID. В узком рабочем пространстве строки показываются карточками.

`@plugin.command("sync_stock", input=SyncInput, output=CatalogRow, target="catalog",
title="Обновить остаток", confirmation="Сохранить новый остаток?")` регистрирует
обработчик `(ctx, target, data)`. `target` содержит `id/revision` либо `None` для общей
команды. Автор проверяет принадлежность и актуальное состояние записи. UI-подтверждение
не является серверным разрешением на запись в FunPay. Существующие Broker guards
сообщений, заказов и лотов сохраняются.

`prefill={"order_id": "order_id"}` заполняет параметр команды полем выбранной записи.
Типы полей должны совпадать. Это удобный старт формы, а не доверенное значение:
обработчик по-прежнему проверяет совпадение с целью и revision. Команду можно открыть
прямо из строки; контекст выбранной записи сохраняется в форме. После получения
результата данные и история обновляются сразу, форма с результатом остаётся открытой.

`ctx` содержит client, config, config_revision, operation_id, installation_id и resource.
Операции принадлежат установке и её серверу. Для нескольких FunPay-аккаунтов автор
сохраняет явную принадлежность своих записей и использует существующие scoped API.

Синхронные и асинхронные обработчики поддерживаются. Для async используйте
`process_once_async()` / `run_forever_async()`. Существующий `@plugin.on(...)` работает.

После изменения деклараций выполните `pulse-plugin schema my_catalog --write`:
обновится только локальный manifest. `verify` проверяет совпадение деклараций с
manifest и выполняет `native-scenarios.json`. Он не собирает и не публикует пакет,
не доказывает работу провайдера и не заменяет тесты бизнес-логики.

## Выполнение, ошибки и повторы

Создание команды возвращает operation ID и состояние `queued`, а не бизнес-успех.
SDK забирает её через существующее событие `events:ui_action`, получает право
исполнения и сохраняет терминальный результат до ack delivery.

- `queued` — ожидает плагин; через 10 минут без начала становится `expired`.
- `running` — выполнение начато; без результата через 2 минуты UI показывает `unknown`.
- `succeeded` — обработчик вернул результат, он прошёл схему и сохранён сервером.
- `failed` — публичная ошибка `CommandError` либо неудачный запрос чтения.
- `unknown` — нельзя подтвердить исход команды. Автоматического повторения эффекта нет.

Поздний результат первоначального исполнителя принимается до подтверждения исхода
через сверку; после неё сохранённый итог уже нельзя заменить. Потеря HTTP-ответа после
сохранения результата и повтор доставки не запускают обработчик второй раз.
Неожиданное исключение не передаётся покупателю: оно может содержать секреты.
Текст `CommandError` публичный, поэтому не включайте в него ключи или данные клиента.

Команды одной установки исполняются последовательно. После падения процесса между
claim и complete сервер сохраняет неизвестный исход и блокирует следующие команды,
пока первоначальная операция не завершена/не сверена. Явный `unknown` также сохраняет
блокировку; чтение данных остаётся доступным. Команда, которая стояла в очереди до
появления блокировки, завершается без запуска с объяснением. Она не мешает доставке
последующей проверки результата.

## Проверка неизвестного результата

Автор добавляет отдельный обработчик, который **только читает фактический результат**:

```python
from funpay_pulse_sdk import CommandOutcome

@plugin.reconcile("exclude_order")
def check_exclusion(ctx, target, data):
    state = repository.load()
    if state.confirms_exclusion(target.id):
        return CommandOutcome("succeeded", Excluded(order_id=data.order_id))
    return CommandOutcome("unknown", message="Исключение пока не подтверждено")
```

Это эскиз: `repository` и `Excluded` принадлежат вашему плагину. Полный рабочий пример
находится в `examples/order_queue_native/native_ui.py`.

В истории появляется «Проверить результат». Pulse создаёт отдельную операцию
`kind="reconcile"` со ссылкой `reconcile_operation_id`. Исходные input/target берутся
из сохранённой команды, их нельзя подменить запросом проверки. В `ctx.operation_id`
обработчик получает ID **исходной команды**, чтобы сверить его с провайдером.
Результат проверки и обновление исходной операции сохраняются в одной транзакции.

- `CommandOutcome("succeeded", result)` — эффект подтверждён, результат проходит
  первоначальную схему output.
- `CommandOutcome("failed", message=...)` — автор подтверждает, что эффект не произошёл
  **и уже не может произойти**, включая ещё работающего старого исполнителя.
- `CommandOutcome("unknown", message=...)` — доказательств нет; блокировка сохраняется.
  Ошибка чтения провайдера, timeout или отсутствие квитанции сами по себе не означают failed.

Исключения проверяющего обработчика, включая `CommandError`, оставляют исход unknown.
Ни повтор проверки, ни потеря её ответа не вызывают основной обработчик команды.
SDK не может гарантировать, что произвольный Python автора действительно read-only;
это контракт обработчика, который автор обязан проверить. Без него либо без
подтверждённой совместимости после обновления требуется разбор автора. Публичной кнопки
«считать выполненным» и безусловного снятия блокировки нет.

### Восстановление после обновления версии

```python
@plugin.reconcile("exclude_order", compatible_versions=["1.0.0"])
def check_previous_exclusion(ctx, target, data):
    # ctx.operation_id — ID первоначальной команды; ctx.original_version — её версия.
    return read_saved_receipt(ctx.operation_id, ctx.original_version)
```

Совместимость объявляет новая версия явно, перечисляя до 32 точных версий.
Исходная команда тоже должна поддерживать reconcile. Сервер требует совпадения
ID команды, входной и выходной схем, коллекции-цели и вида ресурса. Входные параметры
не мигрируются и основной обработчик не запускается. При изменении установленной
версии во время проверки результат не применяется к исходной операции.

Точный повтор старого запроса с прежним ключом возвращает старую операцию даже после
обновления установки. Он не запускает старый обработчик и не создаёт новую команду.

Идемпотентность действует для ключа и полного запроса, включая target и версию.
Повтор с другими параметрами получает 409. Для внешних провайдеров дополнительно
используйте `ctx.operation_id` как бизнес-ключ, когда провайдер это поддерживает.
SDK не обещает exactly-once для произвольного внешнего побочного эффекта.

## Контракт и ограничения

`ui_schema["ui:native"]` имеет version 1. `native_schema.py` — общий семантический
валидатор SDK, License и двух runtime-копий. Проверить parity из корня репозитория:

```sh
python tools/sync_native_sdk.py
```

`--write` синхронизирует только перечисленные source-файлы. CLI/dev остаются инструментами
разработчика и не добавляются в runtime. Верхнеуровневый manifest сохраняет формат 1.0.
Новые серверные поля и capabilities (`native_ui_enabled`, по умолчанию false в старом
ответе) добавлены обратно совместимо. Старые UI actions и dashboard продолжают работать.

Лимиты: 8 коллекций, 16 команд, 32 публичных scalar-поля на модель,
до 100 записей на запрос (UI использует 25), 16 КиБ запрос, 128 КиБ результат,
30 созданий операций в минуту, до 2000 сохраняемых операций на установку.
Результаты и ключи хранятся минимум 7 дней; очистка выполняется при создании новой
операции. `running` и `unknown` не удаляются автоматически. Операции не связаны
внешним FK с delivery, поэтому история результатов независима от Broker delivery.

Секреты не допускаются в публичных моделях. Для них используется отдельное
Broker-хранилище с разрешением `secrets:own`; не включайте значения в config или строки коллекции.
Собственные данные примера сохраняются через существующий `storage:own`; атомарный
CAS, миграции состояния и durable timers ещё не добавлены.

## Сервер и граница выпуска

Новые endpoints находятся под `/api/v2/plugin-marketplace[/desktop]/installations/.../native`
и `/api/v2/broker/native/operations/...`. Desktop использует существующий JWT,
web — cookie + Origin для создания, Broker — токен установки. Чтение и запись
проверяют владельца; выполнение привязано к версии и claim исполнителя.

Для работы на реальной установке нужны совместимые License API и интерфейс Pulse.
Каталог без событий требует также совместимого Worker. Самостоятельный preview
работает из wheel без этих компонентов. При закрытом серверном доступе к native UI
обычные плагины и их dashboard продолжают работать.

Состояния операций и каталог хранятся отдельно; обновление приложения сохраняет
историю. В production PostgreSQL обе таблицы добавляет миграция `20260907_0019`.

В эту версию не входят durable timers, автоматические миграции состояния/CAS,
перенос Cardinal и интерфейс Mini App.

## Пилот существующего меню

`examples/order_queue_native` основан на исходниках Order Queue 0.1.1: прежние
обработчики событий, модель состояния и расчёт очереди сохранены. Добавлен адаптер
из публичных `collection`, `command`, `reconcile`. Он исключает заказ только из
собственной очереди плагина; возврат денег и отмена FunPay-заказа не выполняются.

```sh
pulse-plugin schema examples/order_queue_native --write --trusted
pulse-plugin verify examples/order_queue_native --trusted
pulse-plugin dev examples/order_queue_native --trusted
```

`--trusted` разрешает локальную валидацию уже объявленных trusted scopes исходного
плагина. Он не выдаёт права установки и не заменяет модерацию маркетплейса.
Для Python-вызова генератора предусмотрено `plugin.manifest(base, trusted=True)`.

`native-fixtures.json` содержит только выдуманные записи storage для локального
simulator. `legacy-preview.json` позволяет сравнить прежнюю форму с новым интерфейсом.
Сравнение — просмотр старой формы; сохранение старых настроек в preview не моделируется.
Файлы fixtures никогда не загружаются обычным `create_plugin(BrokerClient.from_env())`.

## Доступность серверной функции

SDK 0.2.0 включает локальный рабочий цикл целиком. Нативный интерфейс реальной
установки требует совместимого сервера License и клиента Pulse; публикация wheel
сама по себе не включает эту функцию для всех установок. Полный каталог требует
совместимого Worker. Серверный флаг `CUSTOM_PLUGIN_NATIVE_UI_ENABLED` по умолчанию
выключен. Оператор может ограничить пилот списком ID лицензий в
`CUSTOM_PLUGIN_NATIVE_UI_ALLOWED_LICENSE_IDS` (через запятую). Некорректный список
закрывает доступ; пустой список не ограничивает включённую функцию. До включения
применяется миграция `20260907_0019` с таблицами операций и каталога.
