Compare commits

..

2 Commits

2201 changed files with 40728 additions and 149860 deletions
-38
View File
@@ -1,41 +1,3 @@
# Не тащить в build-context — экономит время `docker build` (особенно
# на крупных репах с .git под 1GB) и не пачкает финальный образ.
node_modules
**/node_modules
dist
**/dist
.git
.github
.gitignore
.cursor
**/.DS_Store
# State от локальных runtime-сценариев (boot:remote / dev-нода / wallet)
**/blockchain-data
**/wallet-data
**/keosd-data
# CI/coverage/тестовые артефакты
coverage
**/coverage
.nyc_output
*.log
# BMad/blago workspace папки (не относятся к билду образа)
_blago
**/_blago
.bmad-core
.bmad-output
# Локальные секреты (на всякий случай — секреты должны идти через
# build-args / docker-compose environment, не через COPY)
.env
.env.*
!.env-example
**/.env
**/.env.*
!**/.env-example
# Артефакты других сервисов
components/desktop/.quasar
components/contracts/build
-6
View File
@@ -27,12 +27,6 @@ PG_HOST_PORT=5532
COOPBACK_HOST_PORT=2998
DESKTOP_HOST_PORT=2999
OPENSEARCH_HOST_PORT=9200
MINIO_HOST_PORT=9000
MINIO_CONSOLE_HOST_PORT=9001
# MinIO root credentials (только для локального dev; на prod подменяются плейбуком).
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
# URL для скриптов и кода, запускаемого с хоста (boot, networks.sh,
# preactivate.sh, health.ts). Должны соответствовать host-портам выше.
-3
View File
@@ -1,3 +0,0 @@
Trial run of Typecheck workflow on PR → dev.
Created 2026-05-23 to measure end-to-end duration of vue-tsc (desktop) and tsc (controller).
Do not merge; close after measurement.
-86
View File
@@ -1,86 +0,0 @@
name: Build bootstrap container
# Сборка и публикация docker-образа `dicoop/bootcoop` — bootstrap-артефакта
# для развёртывания контрактов на удалённой ноде. Образ содержит CLI
# (`pnpm run boot:remote`) и `/contracts` от dicoop/contracts (multi-stage).
# Используется как one-shot job в стороннем docker-compose рядом с
# `dicoop/blockchain` — никакой mono-инфры (mongo/postgres/desktop) не нужно.
#
# Триггер — push в dev/testnet/main. Тег `dicoop/bootcoop` совпадает с
# веткой и подтягивает соответствующий тег `dicoop/contracts` (так
# контракты и bootstrap всегда синхронны).
#
# Зависимости в hub:
# dicoop/contracts:<branch> — пакет wasm/abi (build-contracts.yaml)
#
# Образ self-contained: собирает workspace из текущего checkout'а,
# не зависит от dicoop/mono-base (который тегается только релиз-тегами).
#
# Секреты:
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
on:
push:
branches: [dev, testnet, main]
paths:
- 'components/boot/**'
- '.github/workflows/build-bootstrap.yaml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Determine docker tags
run: |
case "${{ github.ref_name }}" in
main)
echo "DOCKER_TAG=main" >> $GITHUB_ENV
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
;;
testnet)
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
;;
dev)
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
;;
*)
echo "Unsupported branch ${{ github.ref_name }}" >&2
exit 1
;;
esac
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Pull contracts image
run: docker pull dicoop/contracts:${{ env.DOCKER_TAG }}
- name: Build and push image
run: |
IMAGE="dicoop/bootcoop"
docker build \
--build-arg CONTRACTS_TAG=${{ env.DOCKER_TAG }} \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-f components/boot/Dockerfile \
-t "$IMAGE:${{ env.DOCKER_TAG }}" \
.
docker push "$IMAGE:${{ env.DOCKER_TAG }}"
if [ -n "${{ env.EXTRA_TAG }}" ]; then
docker tag "$IMAGE:${{ env.DOCKER_TAG }}" "$IMAGE:${{ env.EXTRA_TAG }}"
docker push "$IMAGE:${{ env.EXTRA_TAG }}"
fi
SHORT_SHA="${GITHUB_SHA::7}"
docker tag "$IMAGE:${{ env.DOCKER_TAG }}" "$IMAGE:${{ env.DOCKER_TAG }}-$SHORT_SHA"
docker push "$IMAGE:${{ env.DOCKER_TAG }}-$SHORT_SHA"
+156
View File
@@ -0,0 +1,156 @@
name: Build Docker Images
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Debug info
run: |
echo "Текущая ветка:"
git branch --show-current
echo "Последние коммиты:"
git log -n 3 --oneline
echo "Проверяем файлы в директории components/desktop/src-ssr:"
ls -la components/desktop/src-ssr/ || echo "Директория не найдена!"
echo "Проверяем файлы в middlewares:"
ls -la components/desktop/src-ssr/middlewares/ || echo "Директория middlewares не найдена!"
- name: Set docker tags
run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "DOCKER_TAG=$TAG_NAME" >> $GITHUB_ENV
# Проверяем, является ли тег продакшн-тегом (не содержит alpha, beta, rc и т.д.)
if [[ ! $TAG_NAME =~ -(alpha|beta|rc|test) ]]; then
echo "IS_PRODUCTION_TAG=true" >> $GITHUB_ENV
echo "Это продакшн тег, будем добавлять latest"
else
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
echo "Это не продакшн тег, latest не добавляем"
fi
else
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
fi
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Сначала собираем базовый образ с runtime
- name: Build base image
run: |
docker build --target runtime -t dicoop/mono-base:${{ env.DOCKER_TAG }} .
docker push dicoop/mono-base:${{ env.DOCKER_TAG }}
# Если это продакшн тег, добавляем latest
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/mono-base:${{ env.DOCKER_TAG }} dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
# Создаем сервисные образы на основе базового
- name: Build desktop image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.desktop
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/desktop\", \"run\", \"start\"]" >> Dockerfile.desktop
docker build -t dicoop/desktop:${{ env.DOCKER_TAG }} -f Dockerfile.desktop .
docker push dicoop/desktop:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/desktop:${{ env.DOCKER_TAG }} dicoop/desktop:latest
docker push dicoop/desktop:latest
fi
- name: Build controller image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.coopback
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/controller\", \"run\", \"start\"]" >> Dockerfile.coopback
docker build -t dicoop/coopback:${{ env.DOCKER_TAG }} -f Dockerfile.coopback .
docker push dicoop/coopback:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/coopback:${{ env.DOCKER_TAG }} dicoop/coopback:latest
docker push dicoop/coopback:latest
fi
# Build parser image удалён: парсер переехал в отдельный репозиторий
# coopenomics/parser и публикуется как dicoop/parser из его release.yml.
# Двойной источник одного образа создаёт коллизию тегов, поэтому
# здесь сборку парсера больше не делаем.
- name: Build notificator image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notificator
echo "CMD [\"pnpm\", \"-F\", \"coop-notificator\", \"run\", \"start\"]" >> Dockerfile.notificator
docker build -t dicoop/notificator:${{ env.DOCKER_TAG }} -f Dockerfile.notificator .
docker push dicoop/notificator:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/notificator:${{ env.DOCKER_TAG }} dicoop/notificator:latest
docker push dicoop/notificator:latest
fi
- name: Build notifications image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notifications
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/notifications\", \"run\", \"sync\"]" >> Dockerfile.notifications
docker build -t dicoop/notifications:${{ env.DOCKER_TAG }} -f Dockerfile.notifications .
docker push dicoop/notifications:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/notifications:${{ env.DOCKER_TAG }} dicoop/notifications:latest
docker push dicoop/notifications:latest
fi
# Отправка хука для деплоя
- name: Trigger deployment webhook
if: ${{ success() }}
run: |
if [[ $GITHUB_REF == refs/tags/*alpha* ]]; then
# Хук для тестнета (alpha теги)
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '${{ env.DOCKER_TAG }}'
elif [[ $GITHUB_REF == refs/tags/* ]]; then
# Хук для продакшена (остальные теги)
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '${{ env.DOCKER_TAG }}'
fi
# Уведомление в Telegram об успехе
- name: Telegram notify success
if: ${{ success() }}
run: |
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
ADDITIONAL_INFO=" (с тегом latest)"
else
ADDITIONAL_INFO=""
fi
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
# Уведомление в Telegram об ошибке
- name: Telegram notify failure
if: ${{ failure() }}
run: |
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]"
@@ -0,0 +1,18 @@
# .github/workflows/trigger-coopenomics.yml
name: Trigger Contracts Docs Deploy
on:
push:
branches: [dev, testnet, main, capital] # или когда нужно триггерить
jobs:
trigger-coopenomics:
runs-on: ubuntu-latest
steps:
- name: Trigger Coopenomics deployment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.COOPENOMICS_PAT }}
repository: coopenomics/coopenomics # укажи правильный owner/repo
event-type: deploy_from_mono
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
+130
View File
@@ -0,0 +1,130 @@
name: Publish Docs
on:
push:
branches:
- main
- testnet
- dev
jobs:
build-and-publish-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install pnpm
run: npm install -g pnpm
- name: Install Python requirements
run: |
python -m venv venv
source venv/bin/activate
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
working-directory: ./components/docs
- name: Install Node.js dependencies
run: pnpm install
working-directory: ./components/docs
- name: Patch spectaql-config.yml for CI
run: |
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
working-directory: ./components/controller
- name: Show patched spectaql-config.yml
run: cat spectaql-config.yml
working-directory: ./components/controller
- name: Build cooptypes
run: pnpm run build
working-directory: ./components/cooptypes
- name: Generate controller docs
run: pnpm run docs
working-directory: ./components/controller
- name: Copy controller docs
run: |
mkdir -p ./components/docs/docs/graphql
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
- name: Generate sdk docs
run: pnpm run docs
working-directory: ./components/sdk
- name: Copy sdk docs
run: |
mkdir -p ./components/docs/docs/sdk
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
- name: Generate cooptypes docs
run: pnpm run docs
working-directory: ./components/cooptypes
- name: Copy cooptypes docs
run: |
mkdir -p ./components/docs/docs/cooptypes
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
# ── standards-site → /standards/ на docs.цифровой-кооператив.рф ──
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
# публикуется как поддиректория на том же домене. Vite собирает
# с base='/standards/' (см. vite.config.ts), относительные пути
# внутри dist/ и hash-router работают корректно.
# dist кладём в docs/standards/ ДО mkdocs build — так же, как
# graphql/sdk/cooptypes; mkdocs сам включит его в итоговый site/.
- name: Build standards-site
run: pnpm run build
working-directory: ./components/contracts/standards-site
- name: Copy standards-site into docs/standards
run: |
mkdir -p ./components/docs/docs/standards
cp -r ./components/contracts/standards-site/dist/* ./components/docs/docs/standards/
- name: Build docs (mkdocs)
run: |
source venv/bin/activate
mkdocs build
working-directory: ./components/docs
- name: Remove specific large file before publishing
run: |
# Удаляем конкретный большой файл sdk/typedoc.json
rm -f ./components/docs/site/sdk/typedoc.json
# Проверяем, что файл удален
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
echo "ERROR: typedoc.json still exists!"
exit 1
else
echo "SUCCESS: typedoc.json removed successfully"
fi
- name: Publish to GitHub Pages
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
working-directory: ./components/docs
env:
GIT_AUTHOR_NAME: github-actions
GIT_AUTHOR_EMAIL: github-actions@github.com
GIT_COMMITTER_NAME: github-actions
GIT_COMMITTER_EMAIL: github-actions@github.com
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger docs deployment webhook
if: ${{ success() }}
run: |
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
-H 'Content-Type: application/json' \
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
+37
View File
@@ -0,0 +1,37 @@
name: Publish Packages
on:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
build-and-publish:
if: |
startsWith(github.ref, 'refs/tags/v') &&
!contains(github.ref, '-alpha')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Та же мажорная линия pnpm, что и lockfile (lockfileVersion 6.0 = pnpm 8).
# Иначе `npm i -g pnpm` тянет последний pnpm и переписывает pnpm-lock.yaml → Lerna EUNCOMMIT.
- uses: pnpm/action-setup@v4
with:
version: 8.15.8
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lerna run build
- run: pnpm lerna publish from-package --yes
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-450
View File
@@ -1,450 +0,0 @@
name: Release
# Релизный workflow по push'у в testnet/main (FF-промоушн-модель, см.
# scripts/RELEASE.md).
#
# Порядок (через jobs.needs):
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
# 2) publish-packages — npm publish через lerna (только ветка main)
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
# Gitea (только ветка main); он сам тянет mono и
# пересобирает доки.
#
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
#
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
# если релиз контрактов или контейнеров провалился.
#
# Триггер — push в testnet/main с изменением lerna.json. Версию бампает
# `lerna version` ОДИН раз на dev (scripts/cut-release.sh); тот же коммит едет
# вверх по fast-forward (scripts/promote.sh). lerna.json меняется только на
# релизном бампе, поэтому обычные feature-пуши деплой не триггерят. dev в
# триггере НЕТ: cut на dev только бампает версию, деплой начинается с testnet
# (staging) и завершается main (production).
#
# Окружение определяет ВЕТКА (main → production, testnet → staging), а не
# суффикс версии: единая версия едет во все среды, поэтому source of truth —
# ветка, на которую сделан промоушн. Версия читается из закоммиченного
# lerna.json. Гейты npm-publish/доки — `branch == main`.
on:
push:
branches: [testnet, main]
paths:
- 'lerna.json'
# Ручной деплой: выбрать окружение и (опционально) версию/коммит. Нужен для
# отката на старую версию, редеплоя без бампа и точечного hotfix в один контур —
# всё, что FF-промоушн (scripts/promote.sh, только вперёд) сделать не может.
workflow_dispatch:
inputs:
environment:
description: 'Куда деплоить'
type: choice
options:
- testnet
- main
required: true
ref:
description: 'Релизный тег/коммит для сборки (пусто = верхушка выбранного окружения)'
required: false
default: ''
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.resolve.outputs.tag_name }}
branch: ${{ steps.resolve.outputs.branch }}
is_prod: ${{ steps.resolve.outputs.is_prod }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# push → собираем пушнутый коммит; ручной запуск → выбранный ref
# (пусто = верхушка ветки окружения, на которой запущен workflow).
ref: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref) || github.sha }}
- name: Resolve branch, build mode and version
id: resolve
run: |
# Окружение: при ручном workflow_dispatch — из inputs.environment;
# при push — это ветка push'а (testnet/main). Версия/сборка берутся
# из реально вычекнутого дерева, не из ветки-триггера.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BRANCH="${{ github.event.inputs.environment }}"
else
BRANCH="${{ github.ref_name }}"
fi
SHA="$(git rev-parse HEAD)"
# Версия — из закоммиченного lerna.json. Её бампит lerna version ОДИН
# раз на dev (scripts/cut-release.sh), и тот же коммит едет по FF в
# testnet/main. Тэг как триггер больше не используется.
RAW_VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' lerna.json | head -1)"
if [ -z "$RAW_VERSION" ]; then
echo "::error::Не удалось прочитать version из lerna.json"
exit 1
fi
TAG_NAME="v${RAW_VERSION}"
case "$BRANCH" in
main)
BUILD_MODE=prod
CONTRACTS_TAG=main
CONTRACTS_EXTRA=latest
;;
testnet)
BUILD_MODE=test
CONTRACTS_TAG=testnet
CONTRACTS_EXTRA=
;;
dev)
BUILD_MODE=test
CONTRACTS_TAG=dev
CONTRACTS_EXTRA=
;;
*)
echo "::error::Неподдерживаемая ветка $BRANCH (ожидались testnet/main/dev)"
exit 1
;;
esac
# Окружение деплоя определяет ВЕТКА: main → production, иначе → testnet.
# IS_PROD управляет деплой-webhook (PRODUCTION vs TESTNET) и тэгом :latest
# у образов. Раньше критерием был суффикс -alpha у тэга; теперь единая
# версия едет во все среды, поэтому источник истины — ветка промоушна.
if [ "$BRANCH" = "main" ]; then
IS_PROD=true
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
else
IS_PROD=false
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
fi
{
echo "TAG_NAME=$TAG_NAME"
echo "SHA=$SHA"
echo "BRANCH=$BRANCH"
echo "BUILD_MODE=$BUILD_MODE"
echo "CONTRACTS_TAG=$CONTRACTS_TAG"
echo "CONTRACTS_EXTRA=$CONTRACTS_EXTRA"
echo "IS_PROD=$IS_PROD"
echo "WEBHOOK_URL=$WEBHOOK_URL"
} >> "$GITHUB_ENV"
{
echo "tag_name=$TAG_NAME"
echo "branch=$BRANCH"
echo "is_prod=$IS_PROD"
} >> "$GITHUB_OUTPUT"
echo "Release $TAG_NAME → branch=$BRANCH, build_mode=$BUILD_MODE, contracts:$CONTRACTS_TAG, prod=$IS_PROD"
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# === Этап 1: контракты ===
# CDT ставим из .deb (C9S/cdt v4.2.0) прямо в окружение job'а и
# компилируем напрямую — БЕЗ вложенного docker. Почему не build-all.sh:
# тот монтирует $(pwd):/project в sibling-контейнер, а под Gitea
# act_runner сам job исполняется в контейнере → хостовый демон не видит
# этот путь, /project пуст, cmake падает "no CMakeLists.txt".
# build-all.sh остаётся для локальной сборки (оборачивает тот же
# build_contracts_cdt.sh в docker).
# CMakeLists хардкодит toolchain /cdt/build/...; .deb кладёт CDT в
# /usr/opt/cdt/4.2.0, а сам CDTWasmToolchain.cmake указывает на /usr
# абсолютно — поэтому симлинк /cdt/build → /usr/opt/cdt/4.2.0 сводит пути
# без правки CMakeLists.
- name: Install CDT 4.2.0 toolchain (.deb)
run: |
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
# build-essential — для host-компилятора (project() в CMakeLists);
# libz3-4/libtinfo6/libxml2/zlib1g — рантайм бинарей CDT (clang-9,
# ld.lld и т.д.). Пакет cdt объявляет только libcurl4-gnutls-dev,
# поэтому остальные .so ставим явно — в образе dicoop/blockchain они
# были из сборки исходников, из .deb не тянутся.
$SUDO apt-get install -y --no-install-recommends \
curl ca-certificates cmake make build-essential \
libz3-4 libtinfo6 libxml2 zlib1g
curl -fsSL -o /tmp/cdt.deb \
https://git.coopenomics.world/C9S/cdt/releases/download/v4.2.0/cdt_4.2.0-1_amd64.deb
$SUDO apt-get install -y /tmp/cdt.deb
$SUDO mkdir -p /cdt
$SUDO ln -sfn /usr/opt/cdt/4.2.0 /cdt/build
cdt-cpp --version || true
- name: Compile contracts
working-directory: components/contracts
run: |
./build_contracts_cdt.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
- name: Stage contracts docker context
working-directory: components/contracts
run: |
ROOT="docker/.context"
rm -rf "$ROOT"
mkdir -p "$ROOT/contracts"
# Имена контрактов = аргументы add_contract_build(...) только в
# незакомменченных строках (CMake-комментарий начинается с `#`).
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
echo "Contracts to package:"
echo "$NAMES"
MANIFEST="$ROOT/contracts/manifest.json"
pack_one() {
local NAME="$1" WASM="$2" ABI="$3"
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
return 1
fi
mkdir -p "$ROOT/contracts/$NAME"
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
"$NAME" "$SHA_WASM" "$SHA_ABI"
}
{
printf '{\n'
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
printf ' "git_sha": "%s",\n' "$SHA"
printf ' "tag": "%s",\n' "$TAG_NAME"
printf ' "branch": "%s",\n' "$BRANCH"
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf ' "contracts": [\n'
FIRST=1
emit() {
entry=$(pack_one "$@") || return 0
[ $FIRST -eq 0 ] && printf ',\n'
FIRST=0
printf '%s' "$entry"
}
for c in $NAMES; do
if [ "$c" = "system" ]; then
# У system многоконтрактная сборка: разносим под-контракты
# eosio.* как полноценные элементы каталога, сам "system"
# как имя пропускаем.
for sub in build/contracts/system/contracts/eosio.*/; do
[ -d "$sub" ] || continue
sub_name=$(basename "$sub")
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
done
else
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
fi
done
printf '\n ]\n'
printf '}\n'
} > "$MANIFEST"
cp docker/Dockerfile "$ROOT/Dockerfile"
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
chmod +x "$ROOT/entrypoint.sh"
echo "--- manifest.json ---"
cat "$MANIFEST"
- name: Build and push contracts image
working-directory: components/contracts
run: |
# Ретрай push'а: резолвинг registry-1.docker.io на runner'е изредка
# моргает (DNS-таймаут к 127.0.0.53) — 3 попытки с паузой.
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
IMAGE="dicoop/contracts"
SHORT_SHA="${SHA::7}"
docker build \
--label "org.opencontainers.image.revision=$SHA" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$CONTRACTS_TAG" \
./docker/.context
dpush "$IMAGE:$CONTRACTS_TAG"
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
dpush "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
if [ -n "$CONTRACTS_EXTRA" ]; then
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
dpush "$IMAGE:$CONTRACTS_EXTRA"
fi
- name: Verify pushed contracts image
run: |
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" list
echo "---"
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" sha256
# === Этап 2: контейнеры приложений ===
- name: Build and push base image
run: |
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
dpush "dicoop/mono-base:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
dpush dicoop/mono-base:latest
fi
- name: Build and push service images
run: |
dpush() {
local ref="$1" n=1
until docker push "$ref"; do
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
n=$((n+1)); sleep 10
done
}
build_service() {
local SVC="$1" PKG="$2" CMD="$3"
local DOCKERFILE="Dockerfile.$SVC"
{
echo "FROM dicoop/mono-base:$TAG_NAME"
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
} > "$DOCKERFILE"
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
dpush "dicoop/$SVC:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
dpush "dicoop/$SVC:latest"
fi
}
build_service desktop '@coopenomics/desktop' start
build_service coopback '@coopenomics/controller' start
# TEMP: cooparser — пока не мигрировали потребителей на dicoop/parser
# из отдельного coopenomics/parser repo.
build_service cooparser '@coopenomics/parser' start
build_service notificator 'coop-notificator' start
build_service notifications '@coopenomics/notifications' sync
# === Этап 3: webhook деплоя ===
- name: Trigger deployment webhook
run: |
curl -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "$TAG_NAME"
# ============================================================================
# publish-packages — npm publish (бывший publish-packages.yaml).
# Гейт: ветка main. Версия для publish — из закоммиченного package.json
# (lerna publish from-package); её бампит cut-release.sh на dev, тот же коммит
# доезжает до main по FF.
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0
# = pnpm 9/10). Иначе action-setup может подтянуть другую версию и переписать
# pnpm-lock.yaml → Lerna EUNCOMMIT.
# ============================================================================
publish-packages:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lerna run build
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
- name: Generate controller schema.gql (required by sdk prepublishOnly)
working-directory: components/controller
run: pnpm run generate-schema
- run: pnpm lerna publish from-package --yes
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# ============================================================================
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
# Гейт: ветка main.
#
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
# Целевой workflow слушает workflow_dispatch с входами
# mono_sha/mono_ref.
# ============================================================================
trigger-coopenomics-docs:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
run: |
curl -fsSL -X POST \
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
# ============================================================================
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
# (приёмник деплоит доки на своей стороне). Гейт: ветка main.
# ============================================================================
trigger-mono-docs:
needs: release
if: ${{ needs.release.outputs.branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Trigger docs deployment webhook
run: |
curl -fsSL -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
-H 'Content-Type: application/json' \
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
-104
View File
@@ -1,104 +0,0 @@
name: Typecheck
# Валидация TypeScript-типов для desktop (Vue 3 + Quasar) и controller (NestJS).
#
# Зачем это отдельный workflow:
# 1. `quasar build --mode ssr` в release.yaml использует Vite/esbuild и
# vue-tsc отключён через vite-plugin-checker (см. quasar.config.cjs):
# TS-ошибки в .vue/.ts НЕ ловятся при docker build образа desktop.
# 2. У `@coopenomics/controller` нет build-скрипта вовсе — `lerna run build`
# его молча пропускает; в проде `ts-node` стартует и валится на типах
# только в рантайме при загрузке модуля. Кейс PR #392 (rename 1080→1020
# в cooptypes) проскочил именно так.
#
# Триггеры:
# - pull_request на dev — гейт перед мерджем в основную ветку;
# - workflow_call — оставлен для переиспользования, но release.yaml его
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
# проверяются на PR в dev до того, как код доедет до тэга).
#
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
on:
pull_request:
branches: [dev]
workflow_call:
# Новый push в PR-ветку отменяет ещё бегущий typecheck по той же ветке —
# иначе 30-минутные прогоны копятся очередью. Группа по head_ref (исходная
# ветка PR), для workflow_call fallback на ref. Отмена только своих же
# прежних прогонов, разные PR друг друга не трогают.
concurrency:
group: typecheck-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
desktop:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install workspace
run: pnpm install --frozen-lockfile
# Полный lerna build по всему workspace — как в корневом Dockerfile.
# Не --scope, потому что workspace-пакеты unbuild-типа (sdk, inter,
# notifications, factory, cooptypes, …) экспортируют типы только из
# сгенерированного dist/. Без dist/ tsc/vue-tsc валятся на TS2307
# «Cannot find module». Локально это маскируется ts-node-ом, но в
# CI пути резолвятся через package.json → types → dist/.
# См. Graphiti / run #148 root cause 2026-05-23.
- name: Build all workspace packages
run: pnpm lerna run build
# quasar prepare генерирует .quasar/* (auto-imports d.ts, типы для
# quasar плагинов). Без него vue-tsc ругается на отсутствующие
# глобальные компоненты QBtn/QCard/etc.
- name: Quasar prepare
run: pnpm --filter @coopenomics/desktop run prepare
# vue-tsc проверяет и .ts, и <script lang="ts"> в .vue.
# --skipLibCheck — иначе валится на типах из транзитивных deps
# (Quasar/Vue/Vite сами по себе чистые, но в графе есть legacy).
# Из памяти: на dev-сервере висло ~час из-за конкуренции с quasar dev;
# на чистом ubuntu-latest без живого стека ожидаемо в разы быстрее.
# Если упрётся в OOM/таймаут — fallback на `pnpm typecheck`
# (tsc --noEmit --skipLibCheck без vue-tsc, без SFC шаблонов).
- name: vue-tsc (desktop)
run: pnpm --filter @coopenomics/desktop exec vue-tsc --noEmit --skipLibCheck
controller:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install workspace
run: pnpm install --frozen-lockfile
# Полный lerna build по всему workspace — controller импортирует
# @coopenomics/sdk, @coopenomics/inter, @coopenomics/notifications
# помимо cooptypes/factory; их dist/ генерируется unbuild и без
# этого шага tsc валится на 37 TS2307. См. root cause выше.
- name: Build all workspace packages
run: pnpm lerna run build
- name: tsc --noEmit (controller)
run: pnpm --filter @coopenomics/controller run typecheck
-13
View File
@@ -1,5 +1,4 @@
node_modules/
node_modules
lerna-debug.log
components/controller/graph.png
blockchain-data/
@@ -13,15 +12,3 @@ dist/
_blago/
blago/
components/reports-standarts/ВОСХОД/
# Реальные XML-образцы отчётности ПК "Восход" (ИНН 9728130611, рег.СФР 1118018397)
# могут появляться в любых подпапках reports-standarts/ — не пушим.
components/reports-standarts/**/NO_*_9728130611772801001_*.xml
components/reports-standarts/**/СФР_1118018397_*.xml
# Output files for local audit scripts (ledger2 migration)
components/contracts/cpp/ledger2/scripts/out/
.env.testnet
.pnpm-store
schema.gql
.claude
-7
View File
@@ -1,7 +0,0 @@
store-dir=./.pnpm-store
supported-architectures.os[]=darwin
supported-architectures.os[]=linux
supported-architectures.cpu[]=x64
supported-architectures.cpu[]=arm64
supported-architectures.libc[]=glibc
-332
View File
@@ -1,332 +0,0 @@
# mono (coopenomics/mono) — рабочие заметки
Этот файл — общая память агента для пяти чекаутов: `~/mono-ai-1`..`~/mono-ai-5`. Реальный файл лежит в `mono-ai-1/CLAUDE.md`, остальные четыре — симлинки сюда; правки коммитим из mono-ai-1 в ветку `dev`.
## Стек монорепы
| Слой | Где | Технология |
|------|-----|------------|
| Backend (coopback) | `components/controller/` | NestJS 10, TypeScript, TypeORM, GraphQL, EventEmitter2 |
| Frontend (UI пайщика) | **`components/desktop/`** | **Vue 3 + Quasar** в SSR-режиме (`quasar dev --mode ssr`) |
| Контракты | `components/contracts/` | EOSIO/CDT, C++ |
| SDK для frontend | `components/sdk/` | TypeScript, авто-генерится из controller GraphQL schema |
| Parser blockchain | `components/parser/` | TypeScript |
| Boot/orchestration | `components/boot/` | TypeScript |
**Frontend (`components/desktop/`) — FSD структура:** `src/{app,pages,processes,widgets,features,entities,shared,stores,desktops,boot}/`.
Когда план говорит «UI компонент» / «frontend integration» — путь `components/desktop/src/{features|widgets|pages|processes}/<name>/`, расширение `.vue` (composition API + `<script setup lang="ts">`), стили Quasar (QChip/QBtn/QCard/QDialog…), GraphQL через Apollo Client + сгенерированные типы из `components/sdk/`. Dev — `pnpm --filter @coopenomics/desktop run dev` или `devnet` (без SSR).
**НЕ путать:** НЕТ `components/app-cooperative/` — не предлагать. Все frontend-сессии работают в той же монорепе, что и backend. В стеке **никакого React нигде нет**.
## Worktree-политика
**Для mono-ai-4 (базовая ветка `marketplace2`, Стол заказов):** **worktree приветствуется** — изоляция работ + параллельные ветки. Если worktree пуст от `node_modules` и `.env` (pnpm их не дублирует):
```bash
WT=/home/admin/mono-ai-4/.claude/worktrees/<name>
ln -s /home/admin/mono-ai-4/node_modules $WT/node_modules
ln -s /home/admin/mono-ai-4/components/controller/node_modules $WT/components/controller/node_modules
ln -s /home/admin/mono-ai-4/components/controller/.env $WT/components/controller/.env
```
Аналогично для других пакетов, чьи тесты будут запускаться (`components/desktop/node_modules`, `components/sdk/node_modules`). `jest` из bin: `cd $WT/components/controller && ./node_modules/.bin/jest -i <test>` — корректно резолвит ts-jest и подхватывает `.env`.
**Подвох cooptypes:** когда `controller/node_modules` — симлинк на main, пакет внутри `node_modules/cooptypes -> ../../cooptypes` раскрывается **относительно main checkout**. `import { MarketContract } from 'cooptypes'` тянет d.ts из main, не из worktree. Если worktree обновлён, а main позади — TSC падает на отсутствующих типах. **Фикс:** перед TSC в worktree controller'а`git pull` в main checkout до того же коммита (или хотя бы где cooptypes/src синхронен) + `pnpm build` в `main/components/cooptypes/`.
## PR-flow
**Базовая ветка mono-ai-1 — `dev`.** Прямые коммиты в `dev` для мелких фиксов разрешены и не требуют feature-ветки/PR (отменено 2026-05-22 пользователем). Для крупных задач — feature-ветка (`feat/...` или `fix/...`) от dev и PR в dev; merge делает пользователь на GitHub. Push в `main` / `mvp` — по-прежнему только через PR.
**Релиз/деплой — FF-промоушн `dev → testnet → main`** (см. `scripts/RELEASE.md`). Версию бампает `lerna` ОДИН раз на dev (`scripts/cut-release.sh`), тот же коммит едет вверх по fast-forward (`scripts/promote.sh testnet|main`). `testnet`/`main` не несут своих коммитов — только FF-указатели, поэтому merge-конфликтов нет. **В `testnet`/`main` прямых коммитов и повторных бампов версии быть не должно** — это ломает FF. Деплой триггерит push в testnet/main с изменением `lerna.json`; окружение определяет ветка (main → prod). Старые `publish-alpha.sh`/`publish-prod.sh` (merge `-X theirs` + per-branch бамп + back-merge) удалены — они и плодили 20-package.json конфликты.
**Не stash'ить `-u` при unstaged WIP пользователя.** Это создаёт окно для потери при drop/конфликте. Если нужно временно убрать unstaged — `git stash push -- <конкретные-paths>` либо коммит-в-feature, и только свои файлы. Кейс 2026-05-18: после `git stash -u`+`drop` при конфликте потерял WIP пользователя (infra.ts/config.ts/quasar.config.cjs и др.).
### Story-by-story для проекта «Стол заказов» (mono-ai-4 на marketplace2)
Каждая story из MVP-эпиков «Стол заказов» (`coopenomics/mono`, базовая ветка `marketplace2`, чекаут `~/mono-ai-4`, BMad-spec'и `_blago/.../components/3-minimalnyy-produkt/_bmad-output/`) идёт через PR.
Workflow:
1. Worktree от `marketplace2` на feature-ветке `feat/<E>-<S>-<slug>`.
2. Edits + unit-тесты; tsc + jest должны быть зелёные.
3. Commit + push feature-ветку.
4. `gh pr create --base marketplace2 --head feat/<E>-<S>-<slug>`.
5. Merge **пользователем на GitHub** (не вызывать `gh pr merge` без явной просьбы).
6. После merge — fetch + checkout marketplace2 в основной чекаут, удалить feature-ветку + worktree, запустить e2e / blockchain тесты против обновлённого marketplace2.
### Umbrella-PR на эпик vs цепочка PR
При работе story-by-story в одной feature-area **не плодить отдельные PR на каждую story с одинаковой целевой веткой**. Каждый последующий PR показывает кумулятивный diff (всё, что в head минус то, что уже мёрджнуто в base). Если предыдущие PR ещё не смерджены — в diff вылазят дубликаты файлов всех предыдущих stories.
**Default — umbrella-PR на эпик** (для крупных эпиков с >3 stories): одна `feat/<E>-epic` ветка + один PR; каждая story — отдельный коммит. Worktree последовательно, push после каждой story, PR обновляется. Так закрыт Эпик 1 Стола заказов (umbrella #380).
**Альтернатива — stacked-PRs с `base:<prev-feat>`:** каждый PR таргетится на предыдущую feature-ветку. После merge нижнего GitHub автоматически перетарджетит верхние. Требует дисциплины и тулинга.
**Анти-паттерн:** worktree от `feat/1-2-...`, потом от `feat/1-3-...`, и каждый PR в `marketplace2`. Цепочка branches правильная (изоляция), но цепочка PR — нет. Кейс Эпика 1 Стола заказов 2026-05-14: 11 PR `#370-#380` подряд от `marketplace2`, каждый +N stories назад. Пользователь дошёл до review #372 и обнаружил дубли. Закрыл #372-#379, оставил только #380.
## Локальные тесты
**Не запускать полный jest локально** ни в mono-ai-1, ни в mono-ai-4: живой dev-стек в docker (`nodeos`, `controller dev` nodemon, `parser dev`, `n8n`) вешает CPU/RAM и блокирует chain. Полный suite — задача CI после push'а PR.
В mono-ai-4 **запрещён параллельный режим jest** (worker-pool по умолчанию) — это вешает сервер. Если нужен unit-тест — точечный с `--runInBand`:
```bash
pnpm jest tests/unit/marketplace/marketplace-onboarding-service.test.ts --runInBand
```
`pnpm generate-schema` / `pnpm generate-client`**не запускать локально**; та же memory/CPU полка вешает контейнер controller'а. Либо CI, либо пользователь сам когда контейнер остановлен.
Перед коммитом достаточно `tsc --noEmit` (быстрый, не блокирует).
## SDK login canon
`@coopenomics/sdk` экспортирует `Client.create({api_url, chain_url, chain_id})` + метод `client.login(email, wif)`. Он сам:
1. Генерит `now` (ISO timestamp).
2. Подписывает приватным ключом (WIF) через eosjs.
3. Зовёт `Mutations.Auth.Login.mutation` с `{email, now, signature}`.
4. Возвращает `{tokens: {access: {token}, refresh: {token}}, account: {username}}`.
**Не дёргать `Mutations.Auth.Login` напрямую**`LoginInput` ждёт `{email, now, signature}`, генерация подписи внутри SDK Client. Refresh: `Mutations.Auth.Refresh.mutation` с `{access_token, refresh_token}`. Канон используется в `blago-cli/src/session/index.ts` (loginInteractive) и в EMP-коннекторе `connectors/cooperative-tsk-login-connector` (Story 11.5).
## Backend (controller) каноны
### 3 базовые роли — User / Member / Chairman
- **User** — обычный пайщик. Базовые потребительские права (заказывать, публиковать оферту, видеть свои данные).
- **Member** — **член совета** (не «член кооператива»!). User-права + read-only admin (видит склад, поток заказов, повестку — но не модерирует и не подписывает финальные действия).
- **Chairman** — председатель. User + admin (модерация, KU/whitelist/витрины, closing signature АПП-приёмки/выдачи, повестка совета на write).
Маппинг core-роли на extension-роль явно: User → orderer + опционально offerer/operator; Chairman → admin (полный write); Member → read-only admin (board_readonly). Пайщик может быть одновременно в нескольких extension-ролях — массив, не enum. Guard'ы в расширениях — локальные сейчас, в Phase 2 переключатся на платформенный CASL.
### `agreements` ссылается на существующий document registry_id
Когда расширение controller'а (marketplace, blagorost, любое следующее) хранит факт подписи документа пайщиком в глобальной on-chain таблице `agreements`, ссылка идёт через **существующий `registry_id` из платформенного реестра документов**, не через отдельный type-string типа `marketplace.cpp.stol-zakazov-v1`.
Поле `document_id` в `agreements` — FK на существующий platform registry. Extension-таблицы `*_onboarding_requirement``document_registry_id` ссылается на существующий ID. API запросов вида «какие документы подписаны member'ом» — `agreementsByMember(member_id, document_id_filter=[...])`.
**Технический долг платформы:** «Договор УХД сейчас не проходит через `agreements`» — отдельная задача core controller'а, вне scope конкретных расширений.
### Трёхуровневый онбординг расширений
Платформенный паттерн, обязателен для всех новых расширений controller'а.
**L1 — Кооператив (one-time):** председатель/совет принимает решение совета о подключении ЦПП, принимается положение ЦПП (статический документ из platform registry, через document factory с подстановкой параметров кооператива), оферта регистрируется в `coop_registration_offers_registry`.
**L2 — Пайщик при вступлении (per-membership):** в registration-flow появляется **выбор** ЦПП. Пайщик отмечает интересующие, document factory рендерит оферту с `{cooperative_params, member_params, agreement_date}`. Подпись пишется в глобальную on-chain `agreements`. Эта подпись **нивелирует** gate на столе расширения.
**L3 — Пайщик на рабочем столе (per-extension first visit):** backend проверяет через `agreementsByMember` — подписана ли оферта ЦПП. ДА → gate не показывается. НЕТ → gate показывается как explicit consent.
При проектировании любого нового расширения — обязательно три истории под три уровня. На MVP допустимо упростить: одинаковый `document_registry_id` для положения и оферты (физически разные документы могут быть в Phase 2).
### Marketplace asset через DI
В сервисах `components/controller/src/extensions/marketplace/**/*.service.ts` запрещён хардкод вида `const ASSET_DECIMALS = 4` / `const ASSET_SYMBOL = 'RUB'`. Decimals и symbol — через DI `MARKETPLACE_ASSET_CONFIG`:
```ts
@Inject(MARKETPLACE_ASSET_CONFIG) private readonly assetConfig: MarketplaceAssetConfig
// assetConfig.symbol, assetConfig.decimals
```
Канон-пример: `marketplace-order-create.service.ts`. См. `marketplace-asset.config.provider.ts` — мапит `config.blockchain.root_govern_symbol` / `root_govern_precision`. Разные среды (mainnet/testnet/dev) имеют разный symbol+precision.
### registry_id=800 (ReturnByAssetStatement) — клиринг, не членские взносы
Marketplace в монорепе живёт в **двух контурах**:
1. **Система клиринга** (старый, не используется) — registry_id=800. Не использовать в новых фичах.
2. **Система членских взносов** (текущий MVP) — документы лежат **рядом** с актами приёма-передачи и ТТН; другая группа registry_id.
Для нового документа Marketplace-членские-взносы: завести новый registry_id рядом с актами/ТТН и проложить цепочку `cooptypes → factory → controller → desktop`:
1. `@coopenomics/cooptypes`: новый тип документа + регистрация в registry.
2. `@coopenomics/factory`: generator (preview-структура + meta).
3. `@coopenomics/controller`: signed-document DTO + verify в сервисе.
4. `@coopenomics/desktop`: подпись через `Classes.Document` с новым registry_id.
## GraphQL каноны (controller + desktop)
### Описания @Field — бизнес-языком
В `@Field({ description })`, `@InputType`, `@ObjectType` нельзя писать «Story 4.1», «Эпик 3», «FR11a», «composite-entity», «dispatch pipeline», «tx_snapshot», «backend deterministic order_hash». Только пользовательский язык: «Идентификатор заказа», «ПВЗ получения», «Кол-во единиц товара». Story-ссылки и инвариант-комменты — только в inline-комментариях внутри сервиса.
### Enum вместо строковых литералов
Запрещены `if (offer.cycle_type === 'volume_based')`, `status: 'ACTIVE'`, `'PENDING_MODERATION'`. Любое поле с фиксированным набором (cycle_type, status, type, kind, role) — TypeScript `enum`, при необходимости `registerEnumType` для GraphQL. В тестах константы — тоже из enum, не дублирующие строки. Перед добавлением сравнения по строке — `enum MarketplaceOfferCycleType` / `MarketplaceOrderStatus` в `domain/entities/*.types.ts`.
### Пагинация — единый паттерн
В controller-resolver'ах пагинация делается единым каноническим паттерном:
- Вход: `@Args('options', { nullable: true }) options?: PaginationInputDTO` (импорт из `~/application/common/dto/pagination.dto.ts`, поля page/limit/sortBy/sortOrder).
- Выход: `createPaginationResult(ItemDTO, 'PaginatedXxx')` + сигнатура `Promise<PaginationResult<T>>` (items / totalCount / totalPages / currentPage).
- Repository принимает `PaginationInputDTO`, сам считает offset/limit/sort через TypeORM `findAndCount`.
Канон: `time-tracker.resolver.ts`, `expenses-management.resolver.ts`, `generation.resolver.ts`. Никаких локальных `{ limit, offset }`.
### Никаких raw GraphQL-строк в desktop
В `components/desktop/src/pages/**/api/index.ts` и аналогах запрещены конструкции:
```ts
const QUERY = `query Foo { ... }`;
await sendPOST('/v1/graphql', { query: QUERY, variables });
```
Даже с комментарием «техдолг до Zeus regen».
Обязательная процедура перед UI-кодом под новую GraphQL-операцию:
1. Добавить/изменить DTO/resolver в `components/controller/src/...` (code-first).
2. `pnpm run generate-schema` в `components/controller/` → пересоздать `controller/schema.gql`.
3. `pnpm run generate-client` в `components/controller/` → graphql-zeus кладёт клиент в `components/sdk/src/zeus/`.
4. `pnpm run build` в `components/sdk/` → unbuild собирает `dist/`.
5. В desktop импортировать `Mutations.<Domain>.<Name>` / `Queries.<Domain>.<Name>` из `@coopenomics/sdk` — типизировано end-to-end.
Если на шаге 1 не хватает поля — добавить и запустить весь цикл; не оставлять заглушку «пока».
### Строгая типизация desktop API из SDK через IInput['data']
Каждый вызов `client.Mutation` / `client.Query` в `components/desktop/src/**/api/index.ts`:
1. Принимает аргументом объект `data: IXxxInput`, где `IXxxInput = Mutations.<Domain>.<Action>.IInput['data']` (или `Queries...`) — тип берётся **прямо из @coopenomics/sdk**, не переописывается.
2. Передаёт в `variables` объект `data` целиком: `{ variables: { data } }`. Запрещено разворачивать поля: `{ variables: { data: { a, b, c } } }`.
Канон — `features/Branch/CreateBranch/{api,model}/index.ts`: тип в model `export type IXxxInput = Mutations.X.Y.IInput['data']`; функция в api `function (data: IXxxInput) { ... variables: { data } }`. Не делать `as` cast'ов.
## ⚠️ ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН К ИСПОЛЬЗОВАНИЮ
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
**Полная спецификация канона — в skill `/mono-desktop-canon`** (правила обёрток, цвета/токены, иконки, структура страницы, stop-signals). Здесь — короткая выжимка; при расхождении побеждает skill.
**Источник истины — в самом репозитории** (НЕ внешние HTML/прототипы):
| Что | Путь |
|---|---|
| Токены палитры/типографики/spacing/radii (`--p-*`) | `components/desktop/src/css/mono-platform/tokens.css` |
| Прототип-классы компонентов + typography-утилиты `.t-*` | `components/desktop/src/css/mono-platform/components.css` |
| Оверрайды Quasar под канон | `components/desktop/src/css/mono-platform/quasar-canon.css` |
| Минимальная SCSS-палитра Quasar (`$primary`/`$dark`) | `components/desktop/src/css/quasar.variables.scss` |
| Обёртки / доменные блоки / layout | `components/desktop/src/shared/ui/{base,domain,layout}/` |
| **Живой эталон** (`/_dev/ui` в dev-сборке) | `components/desktop/src/pages/_dev/ui/index.vue` |
**При сомнении — открыть `tokens.css` и `_dev/ui/index.vue`, смотреть как сделано там.** Внешний `shared/MONO Design System.html` и `auth-prototype/` каноном НЕ являются — это устаревшие прототипы.
**Запреты:**
- Экран собирается из готовых компонентов: `shared/ui/base` (вместо сырых `q-input`/`q-btn`/`q-card`/`q-table`/`q-chip`/`q-dialog`/`q-select`), `shared/ui/domain` (WalletCard, DataRow, DocumentRow, IdentityPanel…), `shared/ui/layout` (PageHead, PageTabs, AppHeader/AppDrawer). Голый Quasar — только где обёртки нет (`q-icon`, `q-toggle`, `q-list`, `q-menu`, `q-tooltip`, `q-tabs`, `q-separator`, `q-spinner`, `q-inner-loading`…). Props обёрток не угадывать — читать `*.types.ts` рядом.
- Цвет — только токены `var(--p-*)` (поверхности `--p-surface*`, текст `--p-ink*`, линии `--p-line*`, акцент `--p-primary`, статусы `--p-pos/neg/warn/info`) либо utility-классы/color-props. Никаких сырых hex/rgb. Темы light/dark переключаются через `[data-theme]` на `<html>` — токены следуют сами.
- Spacing/радиусы/типографика — токены `--p-1..--p-10` (4px…72px), `--p-r-sm/md/lg/xl`, `--p-fs-*`/`--p-lh-*` либо классы `.t-*`. Без хардкод-px.
- Иконки — `q-icon(name='…')` именами Material Icons. **FontAwesome (`fa-*`) запрещён** — заменять на Material-эквивалент попутно.
- Запрещённые/удаляемые токены: `--mp-*` (старый `marketplace-tokens.scss`), `--tr-*`, `--ds-*`. Легитимны только `--p-*`, `--q-*` (Quasar автоген), `--prog-*`.
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — Quasar-overrides централизованы в `quasar-canon.css`.
**Кейс 2026-05-28:** при сомнении в каноне пошёл искать его во внешнем HTML/auth-prototype вместо репозитория — перевёрстал не туда, переделывал. SoT — репо (`tokens.css` + `_dev/ui`) и skill `/mono-desktop-canon`, не внешние файлы.
## Frontend desktop — English имена
В `components/desktop/` и любом Vue/TS frontend коде **все имена идентификаторов — английские**:
- Имена Vue-компонентов: `BaseInput`, `BaseDialog`, `WalletCard`, `IdentityPanel`.
- Имена файлов и директорий: `shared/ui/BaseInput/BaseInput.vue`.
- CSS-классы: `.base-card`, `.wallet-card__icon`, `.id-panel__hero`.
- SCSS-переменные: `$primary`, `$prog-blagorost`, `$separator-color`.
- TS-типы, интерфейсы, переменные, функции: `interface WalletCardProps`, `function useWalletData()`.
**Why:** Unicode-имена ломают тулчейн (Vite/Webpack резолверы и aliases часто на ASCII-only regex; TypeScript symbol-resolution на не-ASCII нестабильно; ESLint `vue/component-name-in-template-casing` ждёт PascalCase ASCII; импорты `import БазоваяКнопка from '@/shared/ui/БазоваяКнопка'` невыносимы при review).
**Заголовки в .vue, label-ы кнопок, тексты в UI — по-русски** (это user-facing strings). Не путать с правилом «онтологические class_id по-русски» — то про EMP/ТЭМ и blago-документы, не про frontend.
**Кейс 2026-05-18:** при подготовке UX-спецификации для components/desktop ошибочно применил правило русских имён к Vue-компонентам (`БазоваяКнопка`, `БазовоеПолеВвода`); пользователь поправил.
## Стандарты процессов (.standard.yaml) — бизнес-языком
`components/contracts/cpp/**/*.standard.yaml` — документация для методолога/бухгалтера, не для разработчиков контракта. В `purpose`, `description`, `note`, `human` запрещены технические термины:
- никаких «callback», «soviet::exec», «soviet::createagenda», «AUTHORIZE_CALLBACK_SIGNATURE», «type-string», «registry N», «proposed расширение enum'а»;
- никаких «backend formирует», «controller вызывает», «contract отдаёт» — пишем кто что делает на уровне бизнеса (председатель / совет / заказчик / поставщик);
- технические `marketplace::propwroff`-имена в полях `action`/`name`/`triggered_by` оставляем как identifier'ы, но всё человеко-читаемое в `human`/`purpose`/`description` — на бизнес-словаре;
- бухгалтерские проводки `Дт 91 / Кт 10` — допустимы (бухгалтер их понимает);
- если процесс встроен в более общий — ссылаемся на стандарт по имени-человеку («типовой процесс решения совета»), не на техническую реализацию повестки.
Эталоны: `p.mkt.return.standard.yaml`, `p.cap.rid.standard.yaml`, `reg.coop.standard.yaml`. Антипример — `p.mkt.wroff.standard.yaml` в PR #399 review 2026-05-18 (был забит callback-описаниями и «type=mktwroff»).
## Vault & SERVER_SECRET
WIF админ-аккаунта (например `voskhod`) хранится в `vaults` PostgreSQL зашифрованным AES-256-CBC с ключом `sha256(SERVER_SECRET)`. Если `SERVER_SECRET` потом меняли — **старые записи разрушаются**, `decipher.final()` бросает `error:1C800064:Provider routines::bad decrypt`.
**Симптомы:**
- В логе coopback: `[VaultDomainService] Ошибка при получении WIF ключа для пользователя voskhod: bad decrypt`.
- В UI каскад `SignAgreementDialog` не закрывается; SPA отправляет `sendAgreement` корректно, но `wallet::signagree` on-chain не происходит.
- Следствие — `wallet::users[<coop>]` пуст, и любой последующий `is_can_transfer`/трансфер AXON падает на `Отправитель не является участником ЦПП кошелька`.
**Фикс:**
1. Достать живой WIF (для voskhod в dev — `5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3` из boot config.ini, signature-provider).
2. Шифрануть им current SERVER_SECRET (см. `controller/src/utils/aes.ts` — sha256(SECRET) → AES-256-CBC, IV 16 байт, формат `ivHex:cipherHex`).
3. `UPDATE vaults SET wif='...', updated_at=now() WHERE username='voskhod' AND permission='active';`.
4. Перезапуск coopback **не нужен** — он читает каждый раз.
**Дефолт всех mono-репозиториев — `SECRET`.** Если в каком-то `.env` стоит другое — девиация, не норма. Перед re-encrypt'ом vault'а ВСЕГДА сначала смотреть `~/mono-ai-1/components/controller/.env` (или соседнего) — это origin truth для SERVER_SECRET.
**Кейс 2026-05-18 (Эпик 0):** в `mono-ai-5/components/controller/.env` стоял `e2e-fixture-secret-DO-NOT-USE-IN-PROD`, а WIF voskhod в vaults был зашифрован оригиналом — `SECRET`. Фикс — откатить SERVER_SECRET к `SECRET` и подровнять provider'а.
## Capital — фиксы билетов времени (PR #387 merged 2026-05-15)
В `components/controller/src/extensions/capital/` была серия багов в распределении билетов времени, закрытая PR #387 в dev. Три бага:
1. **`recalcDoneEstimatesForContributorProject` / `applyExplicitEstimateToTimeEntries`** раздавал «общий остаток пула» `(estimate total_committed) / N` всем creators, включая того, кто уже закоммитил. Фикс: личная доля `max(0, estimate/N own_committed_estimate)` через общий helper `redistributeIssueEstimateEntries`.
2. **`commitTime` partial split** создавал committed-запись без `entry_type` и `estimate_snapshot` — БД по default'у писала `entry_type='hourly'`, что ломало последующий recalc (фильтрует только `entry_type='estimate'`). Фикс: явно копировать оба поля.
3. **`declineCommit` / `handleDeclineCommit`** меняли только `commit.status='declined'`, но не возвращали `time-entries` в `is_committed=false`. Часы оставались в `total_committed_hours`. Фикс: `revertEntriesForDeclinedCommit` через новые `findCommittedByCommitHash` / `revertCommittedEntriesByCommitHash`.
**При симптомах** «25 ч подтверждено непонятно откуда» / «доступные часы не вернулись после decline» / «парные коммиты на одну работу» — первый чек: `grep -c redistributeIssueEstimateEntries time-tracking.interactor.ts` в контейнере > 5. Если нет — деплой устарел. Если есть, но баг — посмотреть `entry_type` у committed-записей по issue: legacy записи из БАГ #2 могут до сих пор быть с `entry_type='hourly'` для split-результатов от estimate (видны по `commit_hash IS NOT NULL AND entry_type='hourly' AND estimate_snapshot IS NULL` на DONE-задаче с estimate>0). Чинятся UPDATE'ом `entry_type → estimate` с правильным `estimate_snapshot`.
**Legacy data caveat:** `capital_time_entries` с `entry_type='hourly'` + `commit_hash != NULL` могут быть как (а) настоящей hourly работой до установки estimate, так и (б) split-наследием БАГ #2. Различить: если у задачи `estimate>0` и `DONE`, и записи hourly от тех же creators что в estimate-долях — это (б), чинить.
**Edge-case дробных остатков:** при `estimate=2.5` и одном creator, после commit'а 2 ч (`Math.floor < 1` для остатка) остаётся 0.5 ч uncommitted estimate, который никогда не закоммитится. Либо ручной DELETE uncommitted остатка, либо изменить estimate на целое (но estimate в `capital_issues` — on-chain, через UI mutation, не DELETE'ом в БД).
## Стол Заказов MVP — текущее состояние
Проект `1-prilozhenie-stol-zakazov` в blago (coopname voskhod, hash `feabc749…3841f73`). Кооперативная закупка/распределение имущества участка (продукты, товары, услуги); пилот — Красногорск; цель 6 мес: 10 кооперативов / 1200+ пайщиков.
**Локация:** `~/blago/production/1-prilozhenie-stol-zakazov/`.
**Структура артефактов:**
- `_bmad-output/planning-artifacts/` (project-уровень): `prd.md`, `prd-validation-report.md`, `ux-design-specification.md`, `architecture.md` (status:complete 2026-05-12).
- **`components/3-minimalnyy-produkt/_bmad-output/planning-artifacts/epics.md`** (SoT для MVP) — `stepsCompleted=[1,2,3,4]`, `status: complete 2026-05-12`, 65 FRs → 11 эпиков → 57 stories. **НЕ создавать дубль на верхнем уровне `_bmad-output/planning-artifacts/`** — MVP-компонент имеет собственный bmad-output.
- `requirements/` (5 файлов): `04-brif`, `0b-protsessy`, `0f-prd`, `7e-uxui`, `d6-arkhitektura` — дубли _bmad-output (намеренно, см. правило о дублях BMad-артефактов в global memory).
**Locked Decisions L1L9:**
- **L1:** порядок подписей АПП (приёмка поставщик→председатель финально / выдача председатель→заказчик финально).
- **L2:** `o.mkt.payout` baseline `Дт 86 / Кт 51` без счёта 60.
- **L3:** реверты исключены — compensating forward с собственным `operation_id`.
- **L4:** геокарта КУ обязательна на трёх столах; lat/lng в `PlaceDomainEntity`.
- **L5 / L9:** «Системный конструктор ЦПП» → реализован как трёхуровневый онбординг расширений (см. выше).
- **L6:** без отрицательного баланса в MVP (отменяет NFR-R5 PRD).
- **L7:** enum 10 кооп-категорий (овощи/фрукты, молочные, мясо, рыба, хлеб, бакалея, напитки, готовая еда, услуги, прочие).
- **L8:** двухслойная схема подписания — глобальная on-chain `agreements` (SoT) + локальные `*_onboarding_requirement`/`*_onboarding_state` per-extension.
- **L10/L11/L13:** consolidated request backend-only / cycle_type backend variation / pull-модель отчётности.
**Прогресс по эпикам (на 2026-05-15):**
- **Эпик 1** — MERGED в `marketplace2`: PR #368/#370/#371/umbrella #380.
- **Эпик 2 «Сеть ПВЗ»** — MERGED PR #381 (после rebase). Workspace `market-pvz`, KU details + Yandex geocoder, Zeus SDK.
- **Эпик 11 Story 11.1 (Ledger2 canonical actions)** — MERGED PR #375 на C++ стороне. **TS-сторона cooptypes НЕ закрыта**: `cooptypes/src/contracts/marketplace/actions/index.ts` экспортирует только LEGACY клиринговые actions, `interfaces/marketplace.ts` auto-generated из устаревшего ABI. **Блокер Эпика 4** — нужен отдельный pre-эпик PR `feat/S11-1-cooptypes-canonical`: ABI regen через `eosio-abi2ts` из новой `marketplace.abi.json` после `coopcontracts` build, либо ручное добавление 18 canonical actions + canonical tables + canonical interfaces.
- **Эпик 3 «Витрина»** — IN REVIEW. PR #382 (`feat/E3-vitrina`) OPEN MERGEABLE. 6 коммитов (Stories 3.13.5 + BC-sync seam), 67 unit-тестов. Stories 3.4 (CAS counters) — callback-target для Эпика 4 + scaffolding `marketplace-order-sync.service.ts` (skeleton, throw NOT_IMPLEMENTED).
- **Эпики 4-10** — BACKLOG. Issues 598-7 … 598-13 разложены; story-requirements в `598-N-…-requirements/`.
**Открытые фоллоуапы Эпика 1:**
- L3 mutation `marketplaceSignOnboardingOffer` (write-mutation pool + `sndagreement`).
- Source-маркер `'registration_flow'` vs `'extension_gate'` в DTO.
- `marketplaceAcceptCpp` валидация повестки совета — после Эпика 8 (FR40).
- Финальный юридический текст оферты — `todo-tspp-templates.md` (блокирует Stories 1.7/1.9/1.11 в продовой подаче).
- Story 1.5 интеграционный тест против тестовой ноды ЦК — после Эпика 11.
**Реальное состояние brownfield (на marketplace2 @ d98041d):**
| Слой | Готово (membership) | Donor-клиринг (нужно решить судьбу) |
|---|---|---|
| ledger2 C++/TS реестры | 12 операций `o.mkt.*`, счета 10/91, кошельки `w.mkt.member`/`w.mkt.payout`/`w.wal.member`, WalletOp `REVOKE` | — (legacy `o.mkt.supply/recv` удалены) |
| YAML-стандарты `p.mkt.{supply,return,wroff}` | canonical имена actions | — |
| C++ actions (`marketplace.hpp` + 30 .cpp) | **ничего** из canonical | `orderoffer/accept/authcontrib/authreturn/supply/...` ~1660 LOC; legacy Wallet, не ledger2 |
| process-registry в controller | заглушки под membership (`p.mkt.reqst` убран) | — |
| cooptypes/contracts/marketplace TS-actions | нет canonical | 24 клиринговых обёртки |
| controller/application + domain/marketplace | нет canonical | 23 DTO + interactor + resolver + 6 interfaces |
| controller/extensions/marketplace | — | для категорий Ozon (clean arch, 7 TypeORM entities); может пригодиться для каталога Offer'ов |
| controller/extensions/marketplace-cards | — | минимальный каркас карточек |
| desktop/pages/Marketplace | нет canonical | 11 страниц под клиринг; `desktop/extensions/market` пуст |
**Решение 2026-05-12:** стратегии миграции не делаем — donor уже не работает по старой модели; собираем новую membership-модель в существующем контуре, donor-код переписывается / удаляется напрямую без переходников.
В `/bmad-create-architecture` MVP **не вводить adapter-слой и не описывать миграционные пути**. Прямо фиксировать: какие C++ actions/DTO/Vue-страницы из donor-листа удаляются, какие переписываются под canonical (`signsupp/signchair/signiss1/signiss2/acceptbatch/declinebatch/expirecycle/prepship/createorder(новая сигнатура)/cancelorder`), какие сохраняются (shipment/coopstock — если попадают в MVP scope, отдельно проверить).
**Обязательная enforcement-база для backend**`mono-ai-4/components/controller/CLAUDE.md` (Composite-Entity `db/bc/derived`, Write-mutation pool с placeholder/sync_key dedup, ParserClient + Redis Streams, ForkRegistry, ADR-002/008/009/011/012; параметры через `config/blockchain.config.ts`, не magic numbers).
-139
View File
@@ -1,139 +0,0 @@
# Запуск mono dev-стека на macOS
Полная пошаговая инструкция, как поднять backend (`coopback`) + parser (`cooparser`) + блокчейн-ноду + БД на macOS через Docker Desktop. Прошёл — отметь, ниже разобраны типичные грабли.
## TL;DR
```bash
cd ~/dacom-code/foundation/monocoop
docker compose up -d # 1. поднять базу
./scripts/dev-setup-macos.sh # 2. одной командой (см. ниже скрипт)
```
Если скрипта ещё нет — выполняй шаги ниже руками.
## Шаги
### 1. Поднять контейнеры
```bash
docker compose up -d
```
Поднимутся: `node` (NodeOS), `mongo`, `monoredis`, `postgres`, `coopback`, `cooparser`. **MinIO** входит в дефолт. **OpenSearch** — нет (тяжёлый, см. опц. сервисы).
### 2. Проверить чтоб .env'ы указывали на service-имена, а не localhost
`components/controller/.env`:
- `MONGODB_URL=mongodb://mongo:27017/cooperative-x`
- `REDIS_HOST=monoredis`
- `POSTGRES_HOST=postgres`, `POSTGRES_PASSWORD=postgres!23!23`
- `BLOCKCHAIN_RPC=http://node:8888`
- `SIMPLE_EXPLORER_API=http://cooparser:4000`
- `MINIO_ENDPOINT=minio:9000`
- `CHAIN_ID=<реальный chain_id, см. ниже>`
`components/parser/.env`:
- `MONGO_EXPLORER_URI=mongodb://mongo:27017/cooperative-x`
- `REDIS_HOST=monoredis`
- `API=http://node:8888`
- `SHIP=ws://node:8080`
**Почему service-имена:** все контейнеры в bridge-сети `monocoop_default`. Внутри контейнера `127.0.0.1` = сам контейнер, не host. На macOS `network_mode: host` в Docker Desktop работает плохо — используем bridge + service names.
### 3. Узнать живой chain_id
```bash
curl -s http://localhost:8888/v1/chain/get_info | jq -r .chain_id
```
И вписать в `components/controller/.env``CHAIN_ID=...`. **Если chain_id в .env не совпадает с живым — on-chain транзакции упадут на verify, хотя приложение запустится.**
### 4. Если правил .env — пересоздать контейнер (не restart!)
```bash
docker compose up -d --force-recreate --no-deps coopback cooparser
```
`docker restart` **не перечитывает** `env_file`. `--no-deps` — чтобы не пересоздавать БД (потеряются данные).
### 5. Native binding libxmljs2 — пересборка под Linux
На свежем чекауте `pnpm install` запускается на macOS, и `libxmljs2` собирает Mach-O бинарник. В Linux-контейнере он падает:
```
Error: .../xmljs.node: invalid ELF header ERR_DLOPEN_FAILED
```
**Фикс** (один раз после `pnpm install`):
```bash
docker exec monocoop-coopback-1 sh -c '
cd /app/node_modules/.pnpm/libxmljs2@0.37.0/node_modules/libxmljs2 \
&& rm -rf build \
&& PATH="$PWD/node_modules/.bin:$PATH" npm run install
'
docker restart monocoop-coopback-1
```
Проверка — magic bytes Linux ELF:
```bash
docker exec monocoop-coopback-1 sh -c \
'head -c 4 /app/node_modules/.pnpm/libxmljs2@0.37.0/node_modules/libxmljs2/build/Release/xmljs.node | od -c -N 4'
# Должно быть: 177 E L F
# Если cf fa ed fe — это macOS Mach-O, фикс не применился.
```
### 6. Дождаться cold-start coopback (~56 минут)
`ts-node` без cache + DI tree NestJS = долго. Первые логи появляются спустя ~5 минут после старта контейнера.
```bash
docker logs -f monocoop-coopback-1 | grep -E 'NestJS app|Nest application|Error'
```
Готово, когда видишь:
```
Nest application successfully started
NestJS app with Express routes running on port 2998
```
Проверка:
```bash
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"query":"{__typename}"}' http://localhost:2998/v1/graphql
# {"data":{"__typename":"Query"}}
```
## Опциональные сервисы
### OpenSearch (поиск)
```bash
docker compose --profile search up -d opensearch
```
Затем в `components/controller/.env`:
```
OPENSEARCH_ENABLED=true
```
И пересоздать coopback:
```bash
docker compose up -d --force-recreate --no-deps coopback
```
## Грабли (если опять провозился пол-дня)
| Симптом | Причина | Фикс |
|---|---|---|
| `invalid ELF header` | host'овый `pnpm install` положил Mach-O | См. п. 5 — пересборка libxmljs2 в контейнере |
| `MongooseServerSelectionError: ECONNREFUSED 127.0.0.1:27017` | .env на localhost вместо service-имени | См. п. 2 |
| `password authentication failed for user "postgres"` | в .env `POSTGRES_PASSWORD=postgres` вместо `postgres!23!23` | См. п. 2 |
| Coopback ушёл в restart-loop, но рядом есть другой `monocoop-coopback-1` Up | Случайно запустился старый `components/controller/docker-compose.yml` (network_mode: host, без bind-mount, без CMD) | `docker stop coopback && docker rm coopback` |
| `docker restart` не подхватил новые переменные .env | `restart` не перечитывает `env_file` | `docker compose up -d --force-recreate --no-deps <svc>` |
| Coopback зависает на 5+ минут, лога нет | Это нормально для cold-start ts-node | Подождать. CPU должен крутиться ≥50%. Если CPU = 0% — другая проблема |
| `[VaultDomainService] Ошибка при получении WIF... bad decrypt` | SERVER_SECRET не совпадает с тем, чем шифровали WIF в БД | См. CLAUDE.md → раздел Vault & SERVER_SECRET. Дефолт — `SECRET` |
| Транзакции падают на on-chain verify | CHAIN_ID в .env не совпадает с живым | См. п. 3 |
| `Custom endpoint \`minio://9000\` was not a valid URI` на bootstrap | `MINIO_ENDPOINT` без схемы | `MINIO_ENDPOINT=http://minio:9000` (со схемой!) |
| nodeos в restart-loop с `Database dirty flag set` после sleep/crash Docker Desktop | unclean shutdown chain state | `docker stop monocoop-node-1`; `mv blockchain-data/state-history{,.broken-$(date +%s)}`; `docker compose up -d node` — nodeos сам сделает replay |
| Cold-start coopback 10+ минут (вместо обычных 3-5) | ts-node без cache + bind-mount через osxfs + параллельная нагрузка (nodeos replay) | Подождать; в будущем — `tsx` / `ts-node --swc` для dev-режима |
+36 -113
View File
@@ -1,126 +1,49 @@
# Multi-stage build для `dicoop/mono-base`.
#
# Стадии:
# 1. builder — full toolchain: pnpm + lerna + apt dev-headers (gcc,
# libcairo2-dev, ...) + WeasyPrint в venv. Здесь делаются
# `pnpm install` (с devDeps), `lerna run build`, потом
# `pnpm prune --prod` (devDeps выкидываются из workspace
# node_modules — экономит ~1.5GB).
# 2. runtime — slim образ без dev-headers и без gcc. Только runtime
# libs WeasyPrint + готовый /app + venv. Глобальные pnpm/lerna
# ставятся отдельно для удобного запуска `pnpm -F <pkg> run start`.
#
# Финальный размер: ~1-1.5GB (вместо 7.36GB в одностадийной сборке).
# Все потребители mono-base (controller/desktop/parser/notifications/
# notificator/boot) пользуются одним и тем же тонким runtime-образом
# через `FROM dicoop/mono-base:<tag>` + `CMD`.
# ── Stage 1: builder ──────────────────────────────────────────────────
FROM node:22-slim AS builder
WORKDIR /app
# Build-time системные deps. Тяжёлые `-dev`-пакеты (для компиляции
# native-bindings + WeasyPrint native deps) сюда, в runtime их не
# тащим. `--no-install-recommends` экономит ещё ~150MB.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \
build-essential gcc g++ python3-dev \
libcairo2-dev libffi-dev libjpeg-dev libopenjp2-7-dev zlib1g-dev \
libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 libcairo2 \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
# WeasyPrint в venv — переедет в runtime-stage целиком через COPY.
RUN python3 -m venv /venv \
&& /venv/bin/pip install --no-cache-dir WeasyPrint==67
# pnpm пинится через `packageManager` поле в корневом package.json
# (corepack-стандарт). Без пина CI каждый раз тянет latest, а pnpm 10
# ужесточил политику build-скриптов и валит `--frozen-lockfile` с
# ERR_PNPM_IGNORED_BUILDS на native-пакетах (electron / esbuild / …).
RUN corepack enable && npm install -g lerna --no-fund --no-audit
# Сначала манифесты (для кэш-friendly install). `.dockerignore` уже
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
# Сразу копируем все файлы
COPY . .
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
# Отключаем сами загрузки, пакеты всё равно ставятся.
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
PUPPETEER_SKIP_DOWNLOAD=true
# Устанавливаем инструменты
RUN npm install -g pnpm lerna
# Используем существующий lockfile. Если он не совпадает с workspace —
# падать сразу, не дрейфовать незаметно.
RUN pnpm install --frozen-lockfile
# Установка зависимостей
# Используем версию pnpm, совместимую с существующим lock-файлом
RUN pnpm install
# Сборка всех пакетов через lerna-граф (зависимости собираются в
# правильном порядке). Никаких --filter — пусть собирается всё, чтобы
# образ годился любому потребителю mono-base.
# Установка системных зависимостей для WeasyPrint и диагностических утилит (Debian/Ubuntu версии)
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
python3-venv \
gcc \
g++ \
python3-dev \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libpangocairo-1.0-0 \
libcairo2 \
libcairo2-dev \
libffi-dev \
shared-mime-info \
zlib1g-dev \
libjpeg-dev \
libopenjp2-7-dev \
procps \
wget \
&& python3 -m venv /venv \
&& /venv/bin/pip install WeasyPrint==67 \
&& rm -rf /var/lib/apt/lists/*
# Сборка всех компонентов
RUN lerna run build
# Удалить devDeps из всех workspace node_modules. Это и есть основная
# оптимизация размера — typescript, vitest, eslint, unbuild, ts-node-cli,
# desktop-сборщики и пр. удаляются из финального дерева.
#
# - `CI=true` отключает интерактивный prompt («вы уверены что снести
# modules?») — без TTY pnpm иначе абортится с
# ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
# - `--ignore-scripts` подавляет lifecycle hooks (prepare/postinstall)
# после prune. Иначе pnpm дёргает `quasar prepare` в desktop'е,
# а quasar (devDep) уже снесён → ELIFECYCLE.
#
# Все production-сервисы переведены на runtime без devDeps:
# cooparser → `node ./dist/index.cjs`
# notifications → `node ./dist/sync/sync-runner.cjs` (entry добавлен в unbuild)
# coopback → `ts-node` + `tsconfig-paths` (оба в deps)
# desktop → `node dist/ssr/index.js`
# boot:remote → `node /app/components/boot/dist/index.cjs boot:remote`
RUN CI=true pnpm prune --prod --ignore-scripts
# Финальный образ
FROM builder AS runtime
# ── Stage 2: runtime ──────────────────────────────────────────────────
FROM node:22-slim AS runtime
WORKDIR /app
# Только runtime-библиотеки WeasyPrint (без `-dev` headers и без gcc).
# `python3` нужен потому что venv в /venv/bin/python — символическая
# ссылка на системный интерпретатор; без него `weasyprint` падает с
# `python: not found`. `procps`/`wget` — для дебага/healthcheck'ов
# из docker-compose.
#
# `ca-certificates` обязателен: node:22-slim его не содержит, а
# native-биндинги типа @livekit/rtc-node (Rust + rustls + rustls-native-certs)
# читают корни ТОЛЬКО из системного store. Без файла
# /etc/ssl/certs/ca-certificates.crt любой TLS handshake из такого
# биндинга падает с "invalid peer certificate: UnknownIssuer" —
# даже для валидной LE-цепочки (Node-стек при этом работает,
# у него свои вшитые корни). Инцидент 2026-05-21: secretary в
# coopback не мог подключиться к wss://chatcooprtc.coopenomics.world.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 libcairo2 \
libffi8 libjpeg62-turbo libopenjp2-7 zlib1g shared-mime-info \
procps wget \
&& rm -rf /var/lib/apt/lists/* \
&& update-ca-certificates
# Python venv с WeasyPrint, готовый к использованию.
COPY --from=builder /venv /venv
# Настройка переменных окружения
ENV PATH="/venv/bin:$PATH"
# Приложение целиком (с уже урезанными до prod node_modules).
COPY --from=builder /app /app
# Глобальные pnpm/lerna — нужны чтобы потребители mono-base могли
# делать `CMD ["pnpm","-F","<pkg>","run","start"]` в production.
# pnpm активируется через corepack (версия из `packageManager` корневого
# package.json — синхронно со builder-стадией).
RUN corepack enable && npm install -g lerna --no-fund --no-audit
# Sanity-check: WeasyPrint работает и виден через PATH.
RUN weasyprint --version
# Проверка WeasyPrint
RUN weasyprint --version
@@ -1,55 +0,0 @@
### Blago Manage Transcription Memo
Назначение скилла — собрать краткое содержание (memo) встречи по транскрипции звонка, согласовать его с пользователем и опубликовать в backend через `blago` так, чтобы оно появилось в desktop UI на странице транскрипции звонка (поле «Заметка о звонке»).
Право редактировать memo на сервере есть только у председателя и членов совета — убедись, что активная сессия `blago login` принадлежит подходящей роли.
Перед началом прочитай: `~/.claude/config/blago/config.yaml`. Запомни оттуда `workspace_base`, `active_workspace_env` и путь из `workspaces`, соответствующий `active_workspace_env` — назовём его `workspace_path`.
#### Входной артефакт
Транскрипции звонков лежат в `<workspace_path>/<project-slug>/meetings/<stem>.md`. Имя файла `<stem>` — UTC-метка окончания звонка (`YYYY-MM-DD_HHMMSS_mmm`). Этот файл — pull-only: его текст приходит с сервера и в `blago push` не уходит.
Связь meeting-файла с UUID транскрипции хранится в `.blago/index.json` рабочей копии: запись с `entity_type=call_transcription`, `relative_path` равен пути к meeting-файлу, `entity_hash` — UUID транскрипции. Сам `blago transcription memo` этот UUID резолвит автоматически по пути.
`blago pull` всегда кладёт рядом с meeting-файлом `meetings/<stem>.memo.md` (тип `call_transcription_memo`, `entity_hash = "<uuid>:memo"`). Если на сервере memo пустой — файл создаётся пустым. Это сделано специально, чтобы редактирование и публикация шли в одно действие: открыть существующий sibling, дописать, опубликовать. Никаких отдельных шагов «создать файл» делать не нужно.
#### Формат содержимого `.memo.md`
Жёсткие требования к телу memo (применяй всегда, даже без напоминания пользователя):
1. **Первая строка — резюме одним предложением, длиной ≤150 символов.** Это «о чём вообще был звонок». Без markdown-форматирования (без `#`, `*`, `-`, ссылок, цитат), без переноса строки внутри. Считай длину в символах (Unicode codepoints), а не в словах. Если не помещается — переформулируй до сути, не сокращай прилагательными.
2. Следующая пустая строка-разделитель, затем — развёртка по сути. Сжимай до минимума:
- убирай этикет, разогрев, повторы, «слышно? — слышно», частицы;
- объединяй похожие реплики в один пункт;
- оставляй: о чём договорились, что обсудили, какие решения, какие открытые вопросы и кто за что взялся;
- короткие маркированные списки лучше абзацев; имена/числа/даты сохраняй точно.
3. Markdown отображается в desktop UI редактором (Milkdown через `WrappedEditor`). Можно: заголовки `##`/`###`, списки `-`, инлайн `**жирный**`, ссылки. Не используй декоративный markdown, который ничего не добавляет к смыслу.
#### Процедура
1. Получи от пользователя путь к meeting-файлу (или скажи ему: «дай путь к нужному meeting-файлу»). Прочитай meeting-файл целиком.
2. Открой sibling-файл `meetings/<stem>.memo.md` (он уже создан `blago pull`, возможно пустой). Если файла нет — попроси пользователя выполнить `blago pull` для нужного проекта и повтори.
3. Собери summary по правилам выше: первая строка — одно предложение до 150 символов, дальше пустая строка и сжатая суть. По прямому промпту пользователя адаптируй акценты (что выделить, какие выводы). Если промпта нет — попроси его. Запиши результат в существующий sibling.
4. Покажи пользователю обновлённое содержимое `.memo.md` целиком (или дай явную ссылку на файл). Проверь сам: первая строка — одно предложение, ≤150 символов, без markdown. Если нет — переписывай, пока не выполнится. Спроси, всё ли устраивает. Не публикуй без явного «ок / публикуй / поехали».
5. После подтверждения опубликуй memo на сервер:
```bash
blago transcription memo <workspace_path>/<project-slug>/meetings/<stem>.md
```
По умолчанию команда читает `.memo.md` рядом с указанным meeting-файлом. Альтернативы:
- `blago transcription memo <stem>.md --file path/to/draft.md` — взять текст из произвольного файла;
- `blago transcription memo <UUID> --text "<строка>"` — передать текст строкой и адресовать транскрипцию напрямую по UUID.
6. Команда вернёт строку вида `Memo обновлено на сервере: транскрипция <uuid> (символов: N; источник: …)`. Если сервер ответил ошибкой (например, прав не хватает или сессия истекла) — останови работу, сообщи пользователю текст ошибки и предложи `blago login`.
#### Замечания
- `blago pull` всегда создаёт `meetings/<stem>.memo.md` рядом с каждой meeting-транскрипцией — пустой, если на сервере memo пустой. Файл сразу проиндексирован (`entity_type=call_transcription_memo`), поэтому редактирование→`blago transcription memo` идёт без warning'ов.
- Файл `.memo.md` в `blago push` не уходит (артефакты `meetings/` исключены из staging) — это «локальный канал» вокруг серверного поля `memo`.
- Если ты редактировал sibling локально и не публиковал, а на сервере появилась новая версия — повторный `blago pull` запишет git-style маркеры конфликта (`<<<<<<< blago/local``>>>>>>> blago/remote`); разрулите вручную и опубликуй через `blago transcription memo`.
- Если на сервере memo всё ещё пустой, а у тебя локальный черновик не публиковался — `blago pull` сохранит локальный текст и проиндексирует его как baseline; никакие правки не пропадают.
- Чтобы перезаписать опубликованный memo — повтори ту же команду; сервер хранит одно текущее значение поля `memo`.
- Ограничения на длину больше нет (ни на фронте, ни на бэкенде) — можно публиковать развёрнутые саммари с выводами и пунктами.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.7.21-1",
"version": "2026.4.19-alpha-1",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+2 -48
View File
@@ -41,8 +41,6 @@ import { runPush } from '../sync/push.js'
import { runClearStaging, runRemove } from '../sync/remove.js'
import { restoreAllFromServer, RESTORE_ALL_PATH_SENTINELS, runRestore } from '../sync/restore.js'
import { runStatus } from '../sync/status.js'
import { runUpdateTranscriptionMemo } from '../sync/update-transcription-memo.js'
import { writeWorkspaceIndexMarkdown } from '../sync/workspace-index.js'
import { error, formatThrownValue, info, success, warn } from '../ui/output.js'
function startDir(): string {
@@ -238,48 +236,6 @@ export async function runCli(argv: string[]): Promise<void> {
},
)
const transcriptionCmd = program
.command('transcription')
.description(
'Управление транскрипциями звонков: публикация краткого содержания (memo) на сервер (отображается в desktop на странице транскрипции).',
)
transcriptionCmd
.command('memo')
.description(
'Опубликовать краткое содержание (memo) для транскрипции звонка через GraphQL-мутацию chatcoopUpdateTranscriptionMemo. <pathOrId>: путь к `meetings/<stem>.md` (id берётся из .blago/index.json, entityType=call_transcription) или UUID транскрипции. Без --text/--file читает sibling-файл `<stem>.memo.md` рядом с meeting. Доступно председателю и членам совета.',
)
.argument(
'<pathOrId>',
'путь к meeting-файлу или UUID транскрипции',
)
.option('--file <path>', 'путь к файлу с текстом memo (markdown)')
.option('--text <inline>', 'текст memo строкой (приоритет над --file и sibling)')
.action(
async (
pathOrId: string,
opts: { file?: string, text?: string },
) => {
const root = requireRoot()
const cfg = await loadConfig(root)
const ctx = await ensureAuthenticatedContext(root, cfg)
const result = await runUpdateTranscriptionMemo(
ctx,
pathOrId,
{ inlineText: opts.text, filePath: opts.file },
startDir(),
)
const sourceLabel = result.memoSource === 'inline'
? '--text'
: result.memoSource === 'file'
? `--file ${result.memoFile ?? ''}`
: `sibling ${result.memoFile ?? ''}`
success(
`Memo обновлено на сервере: транскрипция ${result.transcriptionId} (символов: ${result.memoLength}; источник: ${sourceLabel}).`,
)
},
)
const delCmd = program
.command('del')
.alias('delete')
@@ -362,9 +318,9 @@ export async function runCli(argv: string[]): Promise<void> {
)
.action(async (paths: string[]) => {
const root = requireRoot()
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity } = await runAdd(root, paths)
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
success(
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}; не blago-сущности (нет type=project/issue/story во frontmatter): ${skippedNonEntity}.`,
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
)
})
@@ -416,7 +372,6 @@ export async function runCli(argv: string[]): Promise<void> {
return
}
const restoredRel = await runRestore(ctx, pathOrId)
await writeWorkspaceIndexMarkdown(root)
success(`Восстановлено с сервера: ${restoredRel}`)
})
@@ -464,7 +419,6 @@ export async function runCli(argv: string[]): Promise<void> {
return
}
await runClean(root)
await writeWorkspaceIndexMarkdown(root)
success('Индекс и staging очищены, каталоги проектов удалены.')
})
@@ -24,8 +24,6 @@ import {
} from '../sync/index-store.js'
import { generateSlug, issueFileRelativePath, workspaceBasePath } from '../sync/layout.js'
import { loadProjectMapsFromIndex } from '../sync/project-index-map.js'
import { refreshParentProjectVersion } from '../sync/refresh-parent.js'
import { writeWorkspaceIndexMarkdown } from '../sync/workspace-index.js'
import { resolveProjectMarker } from './resolve-base.js'
@@ -222,9 +220,7 @@ export async function runCreateIssue(
remote_updated_at: toRemoteIso(createdRow._updated_at),
content_etag_local: etag,
})
await refreshParentProjectVersion(ctx, index, createdRow.project_hash, projRow.parent_hash)
await saveIndex(ctx.root, index)
await appendPathsToStaging(ctx.root, [rel])
await writeWorkspaceIndexMarkdown(ctx.root)
return { relativePath: rel }
}
@@ -21,8 +21,6 @@ import {
import { generateSlug, storyFileRelativePath, workspaceBasePath } from '../sync/layout.js'
import { loadProjectMapsFromIndex } from '../sync/project-index-map.js'
import { issueLinkForStoryPath } from '../sync/push-create.js'
import { refreshParentProjectVersion } from '../sync/refresh-parent.js'
import { writeWorkspaceIndexMarkdown } from '../sync/workspace-index.js'
import { resolveProjectMarker } from './resolve-base.js'
import { storyContentFormatFromCliOption } from './story-format.js'
@@ -69,7 +67,7 @@ async function pickStoryFileRelativePath(
const slug = generateSlug(title) || 'requirement'
const id2 = storyRequirementIdPrefix2(storyRecordId, storyHash)
const dir = issueArg?.titleSlug
? `${basePath.replace(/\\/g, '/')}/issue-requirements/${capitalIdPathPrefix(issueArg.id)}-${issueArg.titleSlug}`
? `${basePath.replace(/\\/g, '/')}/issues/${capitalIdPathPrefix(issueArg.id)}-${issueArg.titleSlug}-requirements`
: `${basePath.replace(/\\/g, '/')}/requirements`
const alt = `${dir}/${id2}-${slug}-${storyHash.slice(0, 6)}.md`
const absAlt = path.join(root, alt)
@@ -197,11 +195,7 @@ export async function runCreateStory(
remote_updated_at: toRemoteIso(created._updated_at),
content_etag_local: etag,
})
if (created.project_hash) {
await refreshParentProjectVersion(ctx, index, String(created.project_hash), projRow.parent_hash)
}
await saveIndex(ctx.root, index)
await appendPathsToStaging(ctx.root, [rel])
await writeWorkspaceIndexMarkdown(ctx.root)
return { relativePath: rel }
}
+1 -23
View File
@@ -38,31 +38,9 @@ export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
}
}
/** Не бросает: возвращает тип сущности blago, если файл — корректный project/issue/story; иначе undefined. */
export function peekBlagoEntityType(raw: string): EntityFrontmatterType | undefined {
let file: ReturnType<typeof matter>
try {
file = matter(raw)
}
catch {
return undefined
}
const t = file.data?.type
if (t === 'project' || t === 'issue' || t === 'story') {
return t
}
return undefined
}
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
const trimmed = body.endsWith('\n') ? body : `${body}\n`
// matter.stringify(string, data) сначала парсит string как frontmatter — body, начинающийся
// с «---», ловится как фронтматтер и роняет js-yaml на коде вида `Authorization: Bearer …`.
// Передаём пустой контент в matter.stringify, чтобы получить только заголовок, и сами клеим body.
// Нормализуем хвост: gray-matter дописывает лишний `\n` к пустому контенту — это даёт
// ложную «грязь» на следующем pull (etag сходит с тем, что было сохранено как простой stringify).
const head = matter.stringify('', data).replace(/\n+$/, '\n')
return `${head}${trimmed}`
return matter.stringify(trimmed, data)
}
export function issueLabelsFromMetadata(metadata: unknown): string[] {
+12 -41
View File
@@ -3,7 +3,6 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { peekBlagoEntityType } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { expandBlagoUserTargetsToRelativePaths } from './capital-target-expand.js'
import { isBlagoSyncExcludedDirName, isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
@@ -29,39 +28,19 @@ async function collectMarkdownFiles(absDir: string): Promise<string[]> {
return out
}
type FileCheck =
| { kind: 'unreadable' }
| { kind: 'non-entity' }
| { kind: 'clean' }
| { kind: 'dirty' }
/**
* Решение по одному файлу для staging:
* — нечитаемый → dirty (push сам сообщит вменяемую ошибку),
* — без корректного frontmatter type → non-entity (README/notes/CLAUDE/AGENTS и пр. не отправляем),
* — есть в индексе и sha256 совпал → clean,
* — иначе dirty.
*/
async function classifyFileForAdd(
root: string,
rel: string,
index: Awaited<ReturnType<typeof loadIndex>>,
): Promise<FileCheck> {
let raw: string
try {
raw = await fs.readFile(path.join(root, rel), 'utf8')
}
catch {
return { kind: 'unreadable' }
}
if (peekBlagoEntityType(raw) === undefined) {
return { kind: 'non-entity' }
}
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
const entry = findByRelativePath(index, rel)
if (!entry) {
return { kind: 'dirty' }
return true
}
try {
const raw = await fs.readFile(path.join(root, rel), 'utf8')
return sha256Hex(raw) !== entry.content_etag_local
}
catch {
return true
}
return sha256Hex(raw) === entry.content_etag_local ? { kind: 'clean' } : { kind: 'dirty' }
}
export interface RunAddResult {
@@ -71,8 +50,6 @@ export interface RunAddResult {
skippedIgnored: number
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
skippedPullOnlyArtifacts: number
/** Файлы без корректного blago-frontmatter (README, notes, CLAUDE/AGENTS и пр.) — в staging не попадают */
skippedNonEntity: number
}
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
@@ -88,7 +65,6 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
let skippedUnchanged = 0
let skippedIgnored = 0
let skippedPullOnlyArtifacts = 0
let skippedNonEntity = 0
for (const t of expanded) {
const abs = path.resolve(root, t)
@@ -110,12 +86,7 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
skippedPullOnlyArtifacts += 1
continue
}
const check = await classifyFileForAdd(root, rel, index)
if (check.kind === 'non-entity') {
skippedNonEntity += 1
continue
}
if (check.kind === 'clean') {
if (!(await isDirtyVsIndex(root, rel, index))) {
skippedUnchanged += 1
continue
}
@@ -125,5 +96,5 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
const stagedPaths = [...set].sort()
await saveStaging(root, { paths: stagedPaths })
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity }
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
}
@@ -9,15 +9,12 @@ export interface CommunicationCursorsFile {
messageLastTsByRoom: Record<string, number>
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
transcriptionLastEndedExclusiveByProject: Record<string, string>
/** matrixRoomId → ISO instant: транскрипции непроектной комнаты с endedAt ≤ этого момента уже выгружены */
transcriptionLastEndedExclusiveByRoom: Record<string, string>
}
function empty(): CommunicationCursorsFile {
return {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -35,11 +32,6 @@ export async function loadCommunicationCursors(root: string): Promise<Communicat
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
? { ...parsed.transcriptionLastEndedExclusiveByProject }
: {},
transcriptionLastEndedExclusiveByRoom:
parsed.transcriptionLastEndedExclusiveByRoom !== undefined
&& typeof parsed.transcriptionLastEndedExclusiveByRoom === 'object'
? { ...parsed.transcriptionLastEndedExclusiveByRoom }
: {},
}
}
catch {
@@ -43,6 +43,7 @@ export function projectCommunicationDayToMarkdown(
}
export interface TranscriptionHeaderModel {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -70,6 +71,7 @@ export function renderCallTranscriptionMarkdown(
const header = [
`# Транскрипция звонка`,
``,
`- Matrix room: \`${transcription.matrixRoomId}\``,
`- LiveKit room: \`${transcription.roomId}\``,
`- Начало: ${start.toISOString()}`,
`- Окончание: ${end ? end.toISOString() : '—'}`,
+3 -37
View File
@@ -24,8 +24,6 @@ import {
removePendingItem,
} from './pending-create.js'
import { loadProjectMapsFromIndex } from './project-index-map.js'
import { refreshParentProjectVersion } from './refresh-parent.js'
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
export type BlagoDeleteKind = 'issue' | 'story'
@@ -106,20 +104,10 @@ export async function runDelete(
}
let entityHash = entry?.entity_hash ?? pending?.entity_hash ?? ''
let parentProjectHash: string | undefined
if (fileContent !== null) {
if (!entityHash && fileContent !== null) {
try {
const parsed = parseBlagoMarkdown(fileContent)
if (!entityHash) {
entityHash = String(parsed.data.hash ?? '').trim()
}
const ph = parsed.data.project_hash
if (ph !== undefined && ph !== null) {
const t = String(ph).trim()
if (t !== '') {
parentProjectHash = t
}
}
entityHash = String(parsed.data.hash ?? '').trim()
}
catch {
// ignore
@@ -158,27 +146,7 @@ export async function runDelete(
const nextEntries = index.entries.filter(
e => !(e.entity_type === entry.entity_type && e.entity_hash === entry.entity_hash),
)
const indexAfter: IndexFile = { entries: nextEntries }
if (remoteDeleted && parentProjectHash) {
const projEntryAfter = indexAfter.entries.find(
e => e.entity_type === 'project' && e.entity_hash === parentProjectHash,
)
let parentHashHint: string | null | undefined
if (projEntryAfter) {
try {
const pAbs = path.join(root, projEntryAfter.relative_path)
const pRaw = await fs.readFile(pAbs, 'utf8')
const pParsed = parseBlagoMarkdown(pRaw)
const v = pParsed.data.parent_hash
parentHashHint = v === undefined || v === null ? undefined : String(v)
}
catch {
parentHashHint = undefined
}
}
await refreshParentProjectVersion(ctx, indexAfter, parentProjectHash, parentHashHint)
}
await saveIndex(root, indexAfter)
await saveIndex(root, { entries: nextEntries })
}
if (isStaged) {
@@ -195,8 +163,6 @@ export async function runDelete(
}
}
await writeWorkspaceIndexMarkdown(root)
return { relativePath: rel, entityHash, remoteDeleted, pendingOnly }
}
+2 -2
View File
@@ -3,8 +3,8 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
/** Всегда: служебные каталоги + маркер «текущий проект» + автогенерируемый INDEX.md (см. workspace-index). */
const DEFAULT_IGNORE = ['.blago/', '.git/', 'current_project.md', 'INDEX.md']
/** Всегда: служебные каталоги + маркер «текущий проект» (не документ Capital). */
const DEFAULT_IGNORE = ['.blago/', '.git/', 'current_project.md']
/** Каталоги BMAD: не документы Capital — не заходим при рекурсии add/remove и не индексируем по любому пути. */
const SYNC_SKIP_DIR_SEGMENTS = new Set(['_bmad', '_bmad_output', '_bmad-output'])
@@ -14,8 +14,6 @@ export type BlagoEntityType =
| 'room_message_day'
/** Файл `meetings/*.md` (транскрипция звонка), только pull с сервера */
| 'call_transcription'
/** Файл `meetings/<stem>.memo.md` — краткое содержание звонка; pull с сервера + публикация через `blago transcription memo` */
| 'call_transcription_memo'
export interface IndexEntry {
entity_type: BlagoEntityType
+1 -1
View File
@@ -118,7 +118,7 @@ export function storyFileRelativePath(
const id2 = storyRequirementIdPrefix2(storyRecordId, storyHash)
if (issue?.titleSlug) {
const ip = capitalIdPathPrefix(issue.id)
return `${basePath}/issue-requirements/${ip}-${issue.titleSlug}/${id2}-${slug}.md`
return `${basePath}/issues/${ip}-${issue.titleSlug}-requirements/${id2}-${slug}.md`
}
return `${basePath}/requirements/${id2}-${slug}.md`
}
@@ -3,16 +3,11 @@
import type { AuthenticatedContext } from '../session/index.js'
import type { IndexFile } from './index-store.js'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { createHash } from 'node:crypto'
import { Queries, Zeus } from '@coopenomics/sdk'
import { sha256Hex } from '../lib/hash.js'
import { formatThrownValue, warn } from '../ui/output.js'
import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js'
import {
loadCommunicationCursors,
@@ -25,7 +20,7 @@ import {
transcriptionMeetingFileStemUtc,
type CommunicationDayLine,
} from './communication-markdown.js'
import { generateSlug, workspaceBasePath, type ProjectPathModel } from './layout.js'
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
import { syncEntityFile } from './sync-entity-file.js'
interface ProjectRowLite {
@@ -47,121 +42,6 @@ function messageDayEntityHash(projectHash: string, utcDate: string): string {
return createHash('sha256').update(`${projectHash}:${utcDate}`, 'utf8').digest('hex')
}
function transcriptionMemoEntityHash(transcriptionId: string): string {
return `${transcriptionId.toLowerCase()}:memo`
}
async function readFileIfExists(abs: string): Promise<string | null> {
try {
return await fs.readFile(abs, 'utf8')
}
catch {
return null
}
}
function normalizeMemoContent(memo: string): string {
if (memo.length === 0) {
return ''
}
return memo.endsWith('\n') ? memo : `${memo}\n`
}
/**
* Гарантирует наличие файла `meetings/<stem>.memo.md` после pull.
*
* Файл создаётся **всегда** (даже если на сервере memo пуст), чтобы пользователь мог редактировать
* sibling «в одно касание», а команда `blago transcription memo` всегда находила его. Конфликты с
* локальными правками разруливаются:
*
* - индекс есть → штатный `syncEntityFile` (он умеет merge-markers «<<<<<<< blago/local …»);
* - индекса нет, файла нет → пишем серверный memo и индексируем (baseline = сервер);
* - индекса нет, файл есть, содержимое совпадает с сервером → просто индексируем (baseline = совпавший контент);
* - индекса нет, файл есть, серверный memo пустой → оставляем локальный черновик и индексируем его (baseline = локальный);
* - индекса нет, файл есть, оба непустые и различаются → пишем merge-markers, индексируем merged.
*/
async function syncTranscriptionMemoFile(params: {
root: string
index: IndexFile
transcriptionId: string
relativePath: string
serverMemo: string
remoteUpdatedAtIso: string
}): Promise<void> {
const { root, index, transcriptionId, relativePath, serverMemo, remoteUpdatedAtIso } = params
const rel = normalizeRelativePath(relativePath)
const abs = path.join(root, rel)
const entityHash = transcriptionMemoEntityHash(transcriptionId)
const serverContent = normalizeMemoContent(serverMemo)
const prev = findByHash(index, 'call_transcription_memo', entityHash)
if (prev) {
await syncEntityFile({
root,
index,
entityType: 'call_transcription_memo',
entityHash,
relativePath: rel,
content: serverContent,
remoteUpdatedAt: remoteUpdatedAtIso,
label: `memo транскрипции ${transcriptionId}`,
})
return
}
await fs.mkdir(path.dirname(abs), { recursive: true })
const local = await readFileIfExists(abs)
if (local === null) {
await fs.writeFile(abs, serverContent, 'utf8')
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(serverContent),
})
return
}
if (local === serverContent) {
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(local),
})
return
}
if (serverContent.trim().length === 0) {
// Сервер ничего не знает — берём локальный черновик как baseline. Опубликовать его можно
// через `blago transcription memo <meeting-path>`.
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(local),
})
return
}
const merged = `<<<<<<< blago/local\n${local}\n=======\n${serverContent}\n>>>>>>> blago/remote\n`
await fs.writeFile(abs, merged, 'utf8')
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(merged),
})
warn(
`Конфликт memo транскрипции ${transcriptionId}: локальный черновик и серверная версия различаются. В «${rel}» записаны маркеры слияния («<<<<<<< blago/local» … «>>>>>>> blago/remote»). Оставьте одну версию текста и опубликуйте через «blago transcription memo».`,
)
}
function dateFromUnknown(value: unknown): Date | undefined {
if (value === null || value === undefined) {
return undefined
@@ -183,19 +63,6 @@ async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
}
/** Стабильная папка непроектной комнаты в `rooms/`. Системные — фиксированные, комнаты секретаря — slug + хвост id (уникальность). */
function nonProjectRoomFolder(kind: string, matrixRoomId: string, displayLabel: string): string {
if (kind === 'MEMBERS') {
return 'komnata-paishchikov'
}
if (kind === 'COUNCIL') {
return 'komnata-soveta'
}
const slug = generateSlug(displayLabel) || 'komnata'
const shortId = createHash('sha256').update(matrixRoomId, 'utf8').digest('hex').slice(0, 6)
return `${slug}-${shortId}`
}
export async function pullProjectCommunicationArtifacts(
ctx: AuthenticatedContext,
index: IndexFile,
@@ -211,7 +78,6 @@ export async function pullProjectCommunicationArtifacts(
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -318,16 +184,13 @@ export async function pullProjectCommunicationArtifacts(
try {
const tKey = row.project_hash
const tExIso = cursors.transcriptionLastEndedExclusiveByProject[tKey]
// Курсор `transcriptionLastEndedExclusiveByProject` влияет ТОЛЬКО на скачивание meeting.md
// (тяжёлый GetTranscription с сегментами). Sibling-файл `.memo.md` синхронизируется для всех
// COMPLETED-транскрипций каждый pull — поле `memo` приходит в лёгком GetTranscriptions.
// Как сообщения с after=0: без курсора — полная выгрузка завершённых транскрипций в meetings/.
// (GitHub-синк при первом запуске только ставит курсор без файлов — для локального зеркала так не делаем.)
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
interface TranscriptionCandidate {
id: string
endedAt: Date
memo: string
updatedAt: Date | undefined
}
const byId = new Map<string, TranscriptionCandidate>()
for (const roomId of matrixIds) {
@@ -340,23 +203,18 @@ export async function pullProjectCommunicationArtifacts(
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
continue
}
if (!(end.getTime() > lowerBoundExclusive.getTime())) {
continue
}
const prev = byId.get(t.id)
if (!prev || end > prev.endedAt) {
byId.set(t.id, {
id: t.id,
endedAt: end,
memo: typeof t.memo === 'string' ? t.memo : '',
updatedAt: dateFromUnknown(t.updatedAt),
})
byId.set(t.id, { id: t.id, endedAt: end })
}
}
}
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
// 1) meeting.md — только новые после курсора (тяжёлый GetTranscription с сегментами).
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
const candidates = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
let maxEnded: Date | null = null
for (const c of newMeetings) {
for (const c of candidates) {
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
variables: { data: { id: c.id } },
})
@@ -370,6 +228,7 @@ export async function pullProjectCommunicationArtifacts(
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
matrixRoomId: String(tr.matrixRoomId),
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
@@ -394,28 +253,10 @@ export async function pullProjectCommunicationArtifacts(
remoteUpdatedAt: toUpdatedIso(c.endedAt),
label: `транскрипция ${c.id}`,
})
if (!maxEnded || c.endedAt > maxEnded) {
maxEnded = c.endedAt
}
}
// 2) sibling .memo.md — для ВСЕХ COMPLETED-транскрипций (бэкфил независимо от курсора).
// Поле tr.memo пришло в лёгком GetTranscriptions, повторных запросов не делаем.
for (const c of allCompleted) {
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const memoRel = `${basePath}/meetings/${stem}.memo.md`
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
await syncTranscriptionMemoFile({
root: ctx.root,
index,
transcriptionId: c.id,
relativePath: memoRel,
serverMemo: c.memo,
remoteUpdatedAtIso: memoRemoteUpdatedAt,
})
}
if (maxEnded) {
cursors.transcriptionLastEndedExclusiveByProject[tKey] = maxEnded.toISOString()
}
@@ -435,207 +276,3 @@ export async function pullProjectCommunicationArtifacts(
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
}
}
/**
* Pull переписки и транскрипций из комнат ВНЕ проектов Capital (пайщики, совет, комнаты секретаря).
* Раскладка — отдельная верхняя папка `rooms/<folder>/{messages,meetings}/`, чтобы не смешивать с
* проектными `meetings/`. Логика идентична проектной, но bucket = одна комната, курсор транскрипций — по matrixRoomId.
*/
export async function pullNonProjectCommunicationArtifacts(
ctx: AuthenticatedContext,
index: IndexFile,
): Promise<void> {
let rooms: { matrixRoomId: string, displayLabel: string, kind: string }[]
try {
const q = await ctx.client.Query(Queries.ChatCoop.ListNonProjectCommunicationRooms.query, {})
rooms = (q[Queries.ChatCoop.ListNonProjectCommunicationRooms.name] ?? []) as typeof rooms
}
catch (e) {
warn(`Список непроектных комнат (chatcoopListNonProjectCommunicationRooms): ${formatThrownValue(e)}`)
return
}
if (rooms.length === 0) {
return
}
let cursors: CommunicationCursorsFile
try {
cursors = await loadCommunicationCursors(ctx.root)
}
catch (e) {
warn(`Курсоры переписки (комнаты): не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
for (const room of rooms) {
const folder = nonProjectRoomFolder(room.kind, room.matrixRoomId, room.displayLabel)
const basePath = `rooms/${folder}`
const roomTitle = room.displayLabel || room.matrixRoomId
// Сообщения комнаты — по календарным суткам UTC новее курсора.
try {
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
const afterTs = last ?? 0
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, afterOriginServerTsExclusive: afterTs } },
})
const dates = (datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []).sort()
for (const utcDate of dates) {
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
})
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
originServerTs: m.originServerTs,
authorLabel: m.authorLabel,
coopUsername: m.coopUsername,
kind: String(m.kind),
bodyText: m.bodyText,
}))
if (lines.length === 0) {
continue
}
const content = projectCommunicationDayToMarkdown(roomTitle, room.matrixRoomId, utcDate, [
{ displayLabel: room.displayLabel, matrixRoomId: room.matrixRoomId, lines },
])
const rel = `${basePath}/messages/${utcDate}.md`
const entityHash = messageDayEntityHash(room.matrixRoomId, utcDate)
await syncEntityFile({
root: ctx.root,
index,
entityType: 'room_message_day',
entityHash,
relativePath: rel,
content,
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
label: `переписка ${utcDate} (${room.matrixRoomId})`,
})
}
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
variables: { data: { matrixRoomId: room.matrixRoomId } },
})
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
}
}
catch (e) {
warn(`Переписка Matrix, комната ${room.matrixRoomId} (${roomTitle}): ${formatThrownValue(e)}`)
}
// Транскрипции звонков комнаты + sibling memo.
try {
const tExIso = cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId]
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
interface TranscriptionCandidate {
id: string
endedAt: Date
memo: string
updatedAt: Date | undefined
}
const byId = new Map<string, TranscriptionCandidate>()
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
variables: { data: { matrixRoomId: room.matrixRoomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
})
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
for (const t of list) {
const end = dateFromUnknown(t.endedAt)
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
continue
}
const prev = byId.get(t.id)
if (!prev || end > prev.endedAt) {
byId.set(t.id, {
id: t.id,
endedAt: end,
memo: typeof t.memo === 'string' ? t.memo : '',
updatedAt: dateFromUnknown(t.updatedAt),
})
}
}
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
let maxEnded: Date | null = null
for (const c of newMeetings) {
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
variables: { data: { id: c.id } },
})
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
continue
}
const tr = pack.transcription
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
const endedAtTr: Date | string | null | undefined
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
},
pack.segments.map(s => ({
speakerName: s.speakerName,
text: s.text,
startOffset: s.startOffset,
endOffset: s.endOffset,
})),
)
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const rel = `${basePath}/meetings/${stem}.md`
const entityHash = c.id.toLowerCase()
await syncEntityFile({
root: ctx.root,
index,
entityType: 'call_transcription',
entityHash,
relativePath: rel,
content: md,
remoteUpdatedAt: toUpdatedIso(c.endedAt),
label: `транскрипция ${c.id}`,
})
if (!maxEnded || c.endedAt > maxEnded) {
maxEnded = c.endedAt
}
}
for (const c of allCompleted) {
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
const memoRel = `${basePath}/meetings/${stem}.memo.md`
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
await syncTranscriptionMemoFile({
root: ctx.root,
index,
transcriptionId: c.id,
relativePath: memoRel,
serverMemo: c.memo,
remoteUpdatedAtIso: memoRemoteUpdatedAt,
})
}
if (maxEnded) {
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = maxEnded.toISOString()
}
else if (tExIso === undefined) {
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = new Date().toISOString()
}
}
catch (e) {
warn(`Транскрипции звонков, комната ${room.matrixRoomId}: ${formatThrownValue(e)}`)
}
}
try {
await saveCommunicationCursors(ctx.root, cursors)
}
catch (e) {
warn(`Не удалось сохранить курсоры переписки (комнаты): ${formatThrownValue(e)}`)
}
}
+1 -5
View File
@@ -28,10 +28,9 @@ import {
storyFileRelativePath,
workspaceBasePath,
} from './layout.js'
import { pullNonProjectCommunicationArtifacts, pullProjectCommunicationArtifacts } from './pull-communication.js'
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
import { scaffoldBmadWorkspacesAfterPull } from './scaffold-bmad-workspace.js'
import { syncEntityFile } from './sync-entity-file.js'
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
interface CapitalProjectRow {
id?: number | null
@@ -329,8 +328,6 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
await pullNonProjectCommunicationArtifacts(ctx, index)
await scaffoldBmadWorkspacesAfterPull(ctx, allProjects, projectByHash)
await saveIndex(ctx.root, index)
@@ -347,7 +344,6 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
currentCoopname: coopname,
})
await reportAndMaybePruneOrphans(ctx.root, orphans, options.prune === true)
await writeWorkspaceIndexMarkdown(ctx.root)
}
async function reportAndMaybePruneOrphans(
+3 -45
View File
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { Mutations, Queries } from '@coopenomics/sdk'
import { parseBlagoMarkdown, peekBlagoEntityType } from '../format/index.js'
import { parseBlagoMarkdown } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { effectiveParentHash } from '../lib/parent-hash.js'
import { warn } from '../ui/output.js'
@@ -28,7 +28,6 @@ import {
pushCreateIssue,
pushCreateStory,
} from './push-create.js'
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
// Ответы GraphQL/Zeus часто дают _updated_at как unknown — сужаем безопасно.
function toIso(v: unknown): string {
@@ -58,30 +57,6 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
staging = await loadStaging(ctx.root)
}
// Не-сущности (README/notes/CLAUDE/AGENTS и пр., в .md без type=project|issue|story) тоже не должны валить весь push.
// Чистим их автоматически — иначе одна забытая заметка блокирует отправку реальных правок.
const stagingAbs = (p: string): string => path.join(ctx.root, normalizeRelativePath(p))
const nonEntityInStaging: string[] = []
for (const p of staging.paths) {
try {
const raw = await fs.readFile(stagingAbs(p), 'utf8')
if (peekBlagoEntityType(raw) === undefined) {
nonEntityInStaging.push(normalizeRelativePath(p))
}
}
catch {
// нечитаемый файл — пусть основной цикл бросит понятную ошибку с путём
}
}
if (nonEntityInStaging.length > 0) {
const drop = new Set(nonEntityInStaging)
const kept = staging.paths.filter(p => !drop.has(normalizeRelativePath(p)))
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
for (const p of nonEntityInStaging) {
warn(`Убрано из staging (нет blago-frontmatter type=project|issue|story): ${p}`)
}
staging = await loadStaging(ctx.root)
}
if (staging.paths.length === 0) {
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь | id проекта | projectId-issueId>')
}
@@ -99,24 +74,8 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
const abs = path.join(ctx.root, n)
const raw = await fs.readFile(abs, 'utf8')
let parsed: ReturnType<typeof parseBlagoMarkdown>
try {
parsed = parseBlagoMarkdown(raw)
}
catch (err) {
// Без префикса путём из staging вылезает «type: undefined» без указания файла.
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
let type: ReturnType<typeof validateParsedForPush>['type']
let hash: string
try {
({ type, hash } = validateParsedForPush(parsed))
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
const parsed = parseBlagoMarkdown(raw)
const { type, hash } = validateParsedForPush(parsed)
const entry = findByHash(index, type, hash)
const pKind = pendingKindForEntityType(type)
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
@@ -330,5 +289,4 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
await saveIndex(ctx.root, index)
await saveStaging(ctx.root, { paths: [...remaining] })
await writeWorkspaceIndexMarkdown(ctx.root)
}
@@ -1,144 +0,0 @@
// После create/del дочерней issue/story сервер бьёт _updated_at родительского проекта.
// Без обновления записи индекса родителя следующий push падает с конфликтом версий
// (а pull может затереть локальные правки маркерами слияния). Этот хелпер освежает
// remote_updated_at в индексе, не теряя несохранённые правки в файле проекта/компонента.
import type { AuthenticatedContext } from '../session/index.js'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { Queries } from '@coopenomics/sdk'
import { projectToFrontmatterAndBody, serializeBlagoMarkdown } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { effectiveParentHash } from '../lib/parent-hash.js'
import {
findByHash,
type IndexFile,
normalizeRelativePath,
upsertEntry,
} from './index-store.js'
interface CapitalProjectRow {
id?: number | null
project_hash: string
title?: string | null
description?: string | null
coopname: string
parent_hash?: string | null
parent_title?: string | null
status?: string | null
created_at?: Date | string | null
_updated_at?: Date | string | null
}
function toIso(v: Date | string | null | undefined): string {
if (v === undefined || v === null) {
return ''
}
if (v instanceof Date) {
return v.toISOString()
}
return new Date(v).toISOString()
}
/**
* Освежить запись индекса родительского проекта/компонента после мутации, которая на сервере
* бьёт его _updated_at (создание/удаление дочерней issue или story).
*
* - Если файл проекта/компонента «чистый» (etag совпадает с индексом) — переписываем файл
* свежим контентом с сервера и обновляем remote_updated_at + content_etag_local.
* - Если файл «грязный» (есть локальные правки) — обновляем только remote_updated_at,
* оставляя локальные правки нетронутыми. После такого освежения blago push сравнит
* индекс с сервером и не найдёт конфликта.
*
* Тихие сбои: проекта нет в индексе / GraphQL недоступен / версия не сменилась → no-op.
*
* Безопасность от параллельной правки другим пользователем: если контент проекта на сервере
* отличается от того, что мы знали в индексе (по реконструированному etag без _updated_at),
* мы не выполняем «оптимистичный» bump — оставляем remote_updated_at прежним, чтобы
* пользователь увидел реальный конфликт при ближайшем push.
*/
export async function refreshParentProjectVersion(
ctx: AuthenticatedContext,
index: IndexFile,
projectHash: string,
parentHashHint: string | null | undefined,
): Promise<void> {
const entry = findByHash(index, 'project', projectHash)
if (!entry) {
return
}
const parentHash = effectiveParentHash(parentHashHint ?? undefined)
let row: CapitalProjectRow | null = null
try {
const q = await ctx.client.Query(Queries.Capital.GetProject.query, {
variables: {
data: {
hash: projectHash,
parent_hash: parentHash,
},
},
})
row = (q[Queries.Capital.GetProject.name] as CapitalProjectRow | null) ?? null
}
catch {
return
}
if (!row) {
return
}
const newRemoteAt = toIso(row._updated_at)
if (!newRemoteAt || newRemoteAt === entry.remote_updated_at) {
return
}
const rel = normalizeRelativePath(entry.relative_path)
const abs = path.join(ctx.root, rel)
let onDisk: string | null = null
try {
onDisk = await fs.readFile(abs, 'utf8')
}
catch {
onDisk = null
}
// Реконструируем «как выглядел бы файл» с сервера, но с _updated_at == прежнее значение из индекса:
// если получится то же содержимое, что в индексе по etag — содержимое (title/description) не менялось,
// bump _updated_at действительно «наш» (вызван дочерней мутацией) и его безопасно записать в индекс.
const previewRow: CapitalProjectRow = { ...row, _updated_at: entry.remote_updated_at }
const { data: prevData, body: prevBody } = projectToFrontmatterAndBody(previewRow)
const previewEtag = sha256Hex(serializeBlagoMarkdown(prevData, prevBody))
const contentUnchangedOnServer = previewEtag === entry.content_etag_local
if (!contentUnchangedOnServer) {
// Сервер опередил нас (правка извне или через другую копию) — не трогаем индекс,
// пусть push выявит реальный конфликт и пользователь сделает осознанный pull.
return
}
const isDirty = onDisk !== null && sha256Hex(onDisk) !== entry.content_etag_local
if (!isDirty) {
const { data, body } = projectToFrontmatterAndBody(row)
const content = serializeBlagoMarkdown(data, body)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, content, 'utf8')
const etag = sha256Hex(await fs.readFile(abs, 'utf8'))
upsertEntry(index, {
entity_type: 'project',
entity_hash: projectHash,
relative_path: rel,
remote_updated_at: newRemoteAt,
content_etag_local: etag,
})
return
}
upsertEntry(index, {
entity_type: 'project',
entity_hash: projectHash,
relative_path: rel,
remote_updated_at: newRemoteAt,
content_etag_local: entry.content_etag_local,
})
}
+2 -2
View File
@@ -44,7 +44,6 @@ import {
} from './layout.js'
import { loadProjectMapsFromIndex } from './project-index-map.js'
import { resolveProjectMarkerFromRelativePath } from './resolve-project-hash-from-path.js'
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
interface CapitalProjectRow {
id?: number | null
@@ -367,6 +366,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
if (entry.entity_type === 'call_transcription') {
interface TranscriptionRestorePack {
transcription: {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -393,6 +393,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
}
const content = renderCallTranscriptionMarkdown(
{
matrixRoomId: tr.matrixRoomId,
roomId: tr.roomId,
startedAt: tr.startedAt,
endedAt: tr.endedAt,
@@ -511,6 +512,5 @@ export async function restoreAllFromServer(ctx: AuthenticatedContext): Promise<R
failures.push({ relativePath: rel, message: formatThrownValue(caught) })
}
}
await writeWorkspaceIndexMarkdown(ctx.root)
return { restored, failures }
}
@@ -4,34 +4,11 @@ import type { BlagoEntityType, IndexFile } from './index-store.js'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { parseBlagoMarkdown, serializeBlagoMarkdown } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { warn } from '../ui/output.js'
import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js'
/**
* Канонический вид для сравнения «есть ли реальный конфликт», без волатильных
* серверных меток времени. updated_at/created_at сервер бьёт при дочерних мутациях
* (создание/удаление issue/story у родителя), и они попадают и во frontmatter, и в
* content_etag_local. Из-за этого raw-sha файла расходится с etag, локальный файл
* считается «грязным», а pull при изменившемся remote_updated_at пишет маркеры слияния —
* хотя содержательно текст совпадает. Сравнение в каноне (без updated_at/created_at)
* отличает настоящую правку от чистого bump'а времени.
*/
function canonicalForCompare(raw: string): string {
try {
const parsed = parseBlagoMarkdown(raw)
const data = { ...parsed.data }
delete data.updated_at
delete data.created_at
return serializeBlagoMarkdown(data, parsed.body)
}
catch {
return raw
}
}
async function ensureDirForFile(absFile: string): Promise<void> {
await fs.mkdir(path.dirname(absFile), { recursive: true })
}
@@ -46,28 +23,6 @@ async function fileExists(abs: string): Promise<boolean> {
}
}
/** После переноса файла прибрать пустые родительские каталоги (но не выше rootAbs). */
async function pruneEmptyParents(absFile: string, rootAbs: string): Promise<void> {
const stopAt = path.resolve(rootAbs)
let dir = path.resolve(path.dirname(absFile))
for (let i = 0; i < 16; i++) {
if (dir === stopAt || !dir.startsWith(`${stopAt}${path.sep}`)) {
return
}
try {
const entries = await fs.readdir(dir)
if (entries.length > 0) {
return
}
await fs.rmdir(dir)
}
catch {
return
}
dir = path.dirname(dir)
}
}
async function readFileIfExists(abs: string): Promise<string | null> {
try {
return await fs.readFile(abs, 'utf8')
@@ -123,7 +78,6 @@ export async function syncEntityFile(params: {
await ensureDirForFile(absNew)
if (await fileExists(absOld)) {
await fs.rename(absOld, absNew)
await pruneEmptyParents(absOld, root)
}
else {
await fs.writeFile(absNew, content, 'utf8')
@@ -145,7 +99,6 @@ export async function syncEntityFile(params: {
await fs.writeFile(absNew, content, 'utf8')
if ((await fileExists(absOld)) && path.resolve(absOld) !== path.resolve(absNew)) {
await fs.unlink(absOld)
await pruneEmptyParents(absOld, root)
}
const etagAfterRename = sha256Hex(await fs.readFile(absNew, 'utf8'))
upsertEntry(index, {
@@ -162,21 +115,6 @@ export async function syncEntityFile(params: {
const dirty
= current !== null && current !== undefined && sha256Hex(current) !== prev.content_etag_local
if (dirty && remoteUpdatedAt !== prev.remote_updated_at) {
// Реальный конфликт — только если содержимое расходится вне волатильных меток времени.
// Если локальный и серверный тексты совпадают по канону (отличие лишь в updated_at/created_at),
// это не конфликт: принимаем серверную версию и лечим etag, без маркеров слияния.
if (current !== null && current !== undefined && canonicalForCompare(current) === canonicalForCompare(content)) {
await ensureDirForFile(absNew)
await fs.writeFile(absNew, content, 'utf8')
upsertEntry(index, {
entity_type: entityType,
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAt,
content_etag_local: sha256Hex(await fs.readFile(absNew, 'utf8')),
})
return
}
const merged = wrapMergeConflictMarkers(current ?? '', content)
await ensureDirForFile(absNew)
await fs.writeFile(absNew, merged, 'utf8')
@@ -1,195 +0,0 @@
// Публикация поля memo транскрипции на сервер (мутация chatcoopUpdateTranscriptionMemo).
//
// Связь meeting-файла с UUID транскрипции: `.blago/index.json` хранит entity_type=call_transcription
// и entity_hash=<uuid в lower-case> для каждого `meetings/<stem>.md` (см. pull-communication.ts).
//
// Текст memo берётся:
// 1) --text "<inline>" — приоритет;
// 2) --file <path> — читать markdown из файла;
// 3) sibling .memo.md — если <pathOrId> это meeting-файл, берём `<meetings>/<stem>.memo.md` рядом.
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { Mutations } from '@coopenomics/sdk'
import type { AuthenticatedContext } from '../session/index.js'
import { sha256Hex } from '../lib/hash.js'
import {
findByRelativePath,
type IndexFile,
loadIndex,
normalizeRelativePath,
saveIndex,
upsertEntry,
} from './index-store.js'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export interface UpdateTranscriptionMemoOptions {
readonly inlineText?: string
readonly filePath?: string
}
export interface UpdateTranscriptionMemoResult {
readonly transcriptionId: string
readonly memoLength: number
readonly memoSource: 'inline' | 'file' | 'sibling'
readonly memoFile?: string
}
/** Пара значений: id транскрипции для мутации + опциональный «meeting абсолютный путь» для sibling-резолва. */
interface ResolvedTranscription {
readonly id: string
readonly meetingAbsPath?: string
}
function resolveTranscription(
ctxRoot: string,
cwd: string,
pathOrId: string,
index: IndexFile,
): ResolvedTranscription {
const trimmed = pathOrId.trim()
if (UUID_RE.test(trimmed)) {
return { id: trimmed.toLowerCase() }
}
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed)
const rel = path.relative(ctxRoot, abs)
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(
`Путь «${pathOrId}» вне рабочей копии blago (${ctxRoot}). Передайте путь относительно корня копии или UUID транскрипции.`,
)
}
const entry = findByRelativePath(index, normalizeRelativePath(rel))
if (!entry) {
throw new Error(
`В .blago/index.json нет записи для «${rel}». Выполните «blago pull», чтобы подтянуть транскрипции.`,
)
}
if (entry.entity_type !== 'call_transcription') {
throw new Error(
`Файл «${rel}» имеет тип «${entry.entity_type}», а не «call_transcription». Передайте путь к meeting-файлу или UUID.`,
)
}
return { id: entry.entity_hash, meetingAbsPath: abs }
}
function buildSiblingMemoPath(meetingAbsPath: string): string {
const dir = path.dirname(meetingAbsPath)
const ext = path.extname(meetingAbsPath)
const stem = path.basename(meetingAbsPath, ext)
return path.join(dir, `${stem}.memo.md`)
}
async function readMemoText(
cwd: string,
resolved: ResolvedTranscription,
options: UpdateTranscriptionMemoOptions,
): Promise<{ text: string, source: UpdateTranscriptionMemoResult['memoSource'], file?: string }> {
if (typeof options.inlineText === 'string') {
return { text: options.inlineText, source: 'inline' }
}
if (options.filePath !== undefined && options.filePath.length > 0) {
const abs = path.isAbsolute(options.filePath) ? options.filePath : path.resolve(cwd, options.filePath)
const text = await fs.readFile(abs, 'utf8')
return { text, source: 'file', file: abs }
}
if (!resolved.meetingAbsPath) {
throw new Error(
'Не указан текст memo. Передайте --text "<строка>" или --file <путь>, либо первым аргументом — путь к meeting-файлу (тогда читается `<stem>.memo.md` рядом).',
)
}
const siblingAbs = buildSiblingMemoPath(resolved.meetingAbsPath)
try {
const text = await fs.readFile(siblingAbs, 'utf8')
return { text, source: 'sibling', file: siblingAbs }
}
catch {
throw new Error(
`Рядом с «${path.basename(resolved.meetingAbsPath)}» нет файла «${path.basename(siblingAbs)}». Выполните «blago pull» (он создаст sibling-файл; пустой, если на сервере memo пустой), затем отредактируйте и повторите команду — либо передайте --file / --text.`,
)
}
}
function toIso(v: unknown, fallback: Date): string {
if (v instanceof Date) {
return v.toISOString()
}
if (typeof v === 'string' || typeof v === 'number') {
const d = new Date(v)
if (!Number.isNaN(d.getTime())) {
return d.toISOString()
}
}
return fallback.toISOString()
}
/**
* После успешной мутации записываем `meetings/<stem>.memo.md` рядом с meeting-файлом и
* заносим запись в `.blago/index.json` с типом `call_transcription_memo`, чтобы:
* - следующий `blago pull` видел индексированный файл и шёл по штатной ветке syncEntityFile;
* - `blago push` его не подхватывал — он внутри `meetings/`, исключённого через `pull-only-paths`.
* Это шаг только при наличии meeting-пути (sibling-резолв из <pathOrId>); UUID-режим без файла — пропускаем.
*/
async function persistSiblingAndIndex(
ctx: AuthenticatedContext,
resolved: ResolvedTranscription,
memoText: string,
remoteUpdatedAtIso: string,
): Promise<{ siblingPath: string } | null> {
if (!resolved.meetingAbsPath) {
return null
}
const ext = path.extname(resolved.meetingAbsPath)
const stem = path.basename(resolved.meetingAbsPath, ext)
const siblingAbs = path.join(path.dirname(resolved.meetingAbsPath), `${stem}.memo.md`)
const content = memoText.endsWith('\n') ? memoText : `${memoText}\n`
await fs.mkdir(path.dirname(siblingAbs), { recursive: true })
await fs.writeFile(siblingAbs, content, 'utf8')
const rel = path.relative(ctx.root, siblingAbs)
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return { siblingPath: siblingAbs }
}
const index = await loadIndex(ctx.root)
upsertEntry(index, {
entity_type: 'call_transcription_memo',
entity_hash: `${resolved.id}:memo`,
relative_path: normalizeRelativePath(rel),
remote_updated_at: remoteUpdatedAtIso,
content_etag_local: sha256Hex(content),
})
await saveIndex(ctx.root, index)
return { siblingPath: siblingAbs }
}
export async function runUpdateTranscriptionMemo(
ctx: AuthenticatedContext,
pathOrId: string,
options: UpdateTranscriptionMemoOptions,
cwd: string,
): Promise<UpdateTranscriptionMemoResult> {
const index = await loadIndex(ctx.root)
const resolved = resolveTranscription(ctx.root, cwd, pathOrId, index)
const memo = await readMemoText(cwd, resolved, options)
const mutationName = Mutations.ChatCoop.UpdateTranscriptionMemo.name
const response = await ctx.client.Mutation(Mutations.ChatCoop.UpdateTranscriptionMemo.mutation, {
variables: { data: { id: resolved.id, memo: memo.text } },
})
const row = response[mutationName] as { updatedAt?: unknown } | undefined
if (!row) {
throw new Error('Сервер вернул пустой ответ на chatcoopUpdateTranscriptionMemo.')
}
const remoteUpdatedAtIso = toIso(row.updatedAt, new Date())
const persisted = await persistSiblingAndIndex(ctx, resolved, memo.text, remoteUpdatedAtIso)
return {
transcriptionId: resolved.id,
memoLength: memo.text.length,
memoSource: memo.source,
memoFile: persisted?.siblingPath ?? memo.file,
}
}
@@ -1,181 +0,0 @@
// INDEX.md в корне рабочей копии: для быстрого поиска проекта/компонента/требования
// по id или фрагменту названия (LLM-агентам не нужно ходить по дереву ls'ом).
// Задачи (issues) сюда намеренно не попадают: их слишком много, ищут их с привязкой
// к проекту/компоненту, а сам проект/компонент находится по этому индексу.
// Обновляется автоматически на pull/push/create/del/restore/clean.
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { parseBlagoMarkdown } from '../format/index.js'
import { effectiveParentHash } from '../lib/parent-hash.js'
import { loadIndex, normalizeRelativePath } from './index-store.js'
interface ProjectRow {
readonly id?: string
readonly hash: string
readonly title: string
readonly rel: string
readonly parentHash?: string
}
interface StoryRow {
readonly id?: string
readonly hash: string
readonly title: string
readonly rel: string
readonly projectHash?: string
readonly issueHash?: string
}
const INDEX_MARKDOWN_FILENAME = 'INDEX.md'
function pickIdString(raw: unknown): string | undefined {
if (raw === undefined || raw === null) {
return undefined
}
const s = String(raw).trim()
return s === '' ? undefined : s
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** Идентификаторы требований приходят как UUID — для индекса оставляем первые 8 символов
* (uniqueness в рамках копии практически 100%, поиск по полному id всё равно работает через frontmatter). */
function shortDisplayId(id: string | undefined): string {
if (id === undefined) {
return '?'
}
if (UUID_RE.test(id)) {
return id.slice(0, 8)
}
return id
}
function sortByIdNumeric<T extends { id?: string, title: string }>(a: T, b: T): number {
const ai = a.id ?? ''
const bi = b.id ?? ''
const cmp = ai.localeCompare(bi, 'en', { numeric: true, sensitivity: 'base' })
if (cmp !== 0) {
return cmp
}
return a.title.localeCompare(b.title, 'ru')
}
export async function writeWorkspaceIndexMarkdown(root: string): Promise<void> {
const index = await loadIndex(root)
const projects: ProjectRow[] = []
const stories: StoryRow[] = []
const issueIdByHash = new Map<string, string>()
for (const e of index.entries) {
if (e.entity_type !== 'project' && e.entity_type !== 'story' && e.entity_type !== 'issue') {
continue
}
const rel = normalizeRelativePath(e.relative_path)
const abs = path.join(root, rel)
let raw: string
try {
raw = await fs.readFile(abs, 'utf8')
}
catch {
continue
}
let parsed
try {
parsed = parseBlagoMarkdown(raw)
}
catch {
continue
}
const id = pickIdString(parsed.data.id)
const title = String(parsed.data.title ?? '').trim()
if (e.entity_type === 'project') {
const parent = effectiveParentHash(
parsed.data.parent_hash === undefined || parsed.data.parent_hash === null
? undefined
: String(parsed.data.parent_hash),
)
projects.push({ id, hash: e.entity_hash, title, rel, parentHash: parent })
}
else if (e.entity_type === 'issue') {
// Задачи в INDEX.md не выводятся (слишком много шума), но id нужен
// для подписи под требованиями, привязанными к задаче (issue_hash).
if (id !== undefined) {
issueIdByHash.set(e.entity_hash, id)
}
}
else {
const projectHash = parsed.data.project_hash === undefined || parsed.data.project_hash === null
? undefined
: String(parsed.data.project_hash).trim() || undefined
const issueHash = parsed.data.issue_hash === undefined || parsed.data.issue_hash === null
? undefined
: String(parsed.data.issue_hash).trim() || undefined
stories.push({ id, hash: e.entity_hash, title, rel, projectHash, issueHash })
}
}
projects.sort(sortByIdNumeric)
stories.sort(sortByIdNumeric)
const projectIdByHash = new Map<string, string>()
for (const p of projects) {
projectIdByHash.set(p.hash, p.id ?? p.hash.slice(0, 8))
}
const rootProjects = projects.filter(p => !p.parentHash)
const components = projects.filter(p => p.parentHash)
const out: string[] = []
out.push('# Blago Workspace Index')
out.push('')
out.push(
'> Сгенерировано автоматически (`blago pull` / `push` / `create` / `del` / `restore`). '
+ 'Не править вручную — будет перезаписано.',
)
out.push('')
out.push('## Проекты')
if (rootProjects.length === 0) {
out.push('_(пусто)_')
}
else {
for (const p of rootProjects) {
out.push(`- **${shortDisplayId(p.id)}** — ${p.title || '(без названия)'}\`${p.rel}\``)
}
}
out.push('')
out.push('## Компоненты')
if (components.length === 0) {
out.push('_(пусто)_')
}
else {
for (const c of components) {
const parentLabel = c.parentHash ? projectIdByHash.get(c.parentHash) ?? '?' : '?'
out.push(`- **${shortDisplayId(c.id)}** — ${c.title || '(без названия)'} (проект ${parentLabel}) → \`${c.rel}\``)
}
}
out.push('')
out.push('## Требования')
if (stories.length === 0) {
out.push('_(пусто)_')
}
else {
for (const s of stories) {
const ownerLabel = s.projectHash ? projectIdByHash.get(s.projectHash) ?? '?' : '?'
const issueLabel = s.issueHash ? issueIdByHash.get(s.issueHash) : undefined
const owner = issueLabel
? `задача ${issueLabel} в ${ownerLabel}`
: `проект/компонент ${ownerLabel}`
out.push(
`- **${shortDisplayId(s.id)}** — ${s.title || '(без названия)'} (${owner}) → \`${s.rel}\``,
)
}
}
out.push('')
await fs.writeFile(path.join(root, INDEX_MARKDOWN_FILENAME), `${out.join('\n')}`, 'utf8')
}
-85
View File
@@ -1,85 +0,0 @@
# Образ-bootstrap для удалённой ноды.
#
# Назначение: one-shot job в чужом docker-compose рядом с уже
# запущенным `node`-сервисом (dicoop/blockchain). Bootstrap
# дожидается готовности RPC, после чего вкатывает на ноду все
# системные и кооперативные контракты, активирует протокол-фичи,
# создаёт системный токен и инициализирует system/emission/powerup.
#
# Контракты НЕ собираются здесь — они тянутся multi-stage из
# готового артефакта `dicoop/contracts:<tag>`.
#
# Сборка идёт в три стадии:
# 1. builder — full workspace + pnpm install + unbuild
# (workspace-deps cooptypes/factory inline'ятся в
# bundle, npm-deps остаются external)
# 2. contracts — dicoop/contracts:<tag>
# 3. runtime — slim node:22 + bundle + npm install --omit=dev
#
# Окружение:
# CHAIN_URL — endpoint удалённой ноды
# CONTRACTS_DIR=/contracts
# RPC_WAIT_TIMEOUT_MS — default 120000
# EOSIO_PUB_KEY / EOSIO_PRV_KEY
# INSTALL_INITIAL_DATA=1 — опционально mono-данные (нужны MONGO_URI/POSTGRES_*)
# INSTALL_EXTRA_DATA=1 — то же + расширенный совет
ARG CONTRACTS_TAG=latest
# ─── Stage 1: builder ─────────────────────────────────────────────────
FROM node:22-slim AS builder
WORKDIR /app
RUN npm install -g pnpm@10.33.0
COPY . .
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
&& pnpm rebuild --filter "@coopenomics/boot..."
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
# свой bundle с inlined workspace-deps.
RUN pnpm --filter "@coopenomics/boot..." build
# Workspace-deps inline'ятся в bundle, их транзитивные npm-deps factory
# (handlebars/ajv/mongodb/...) мерджим в boot.deps — нужны в runtime.
# Cooptypes type-only — его @typescript-eslint/parser в runtime не
# нужен и конфликтует с eslint v9 в boot.devDeps по peer-spec'ам.
# Удаляем сами workspace-spec'и (npm install не понимает workspace:*).
RUN node -e "const fs=require('fs'); \
const boot=JSON.parse(fs.readFileSync('components/boot/package.json','utf8')); \
const factory=JSON.parse(fs.readFileSync('components/factory/package.json','utf8')); \
boot.dependencies = { ...factory.dependencies, ...boot.dependencies }; \
delete boot.dependencies['cooptypes']; \
delete boot.dependencies['@coopenomics/factory']; \
fs.writeFileSync('components/boot/package.json', JSON.stringify(boot, null, 2));"
# ─── Stage 2: contracts ───────────────────────────────────────────────
FROM dicoop/contracts:${CONTRACTS_TAG} AS contracts
# ─── Stage 3: runtime ────────────────────────────────────────────────
FROM node:22-slim AS runtime
WORKDIR /app
COPY --from=builder /app/components/boot/package.json ./package.json
COPY --from=builder /app/components/boot/dist ./dist
# Только prod npm-зависимости. --no-package-lock потому что
# pnpm-lock несовместим с npm и в slim-образе не нужен.
RUN npm install --omit=dev --no-package-lock --no-fund --no-audit --legacy-peer-deps \
&& npm cache clean --force \
&& rm -rf /root/.npm
COPY --from=contracts /contracts /contracts
ENV CONTRACTS_DIR=/contracts
ENTRYPOINT ["node", "dist/index.cjs"]
CMD ["boot:remote"]
-71
View File
@@ -51,77 +51,6 @@ pnpm install --filter @coopenomics/boot
Подробное описание переменных — в файле `.env-example`.
## Запуск bootstrap'а в стороннем проекте (sideboot)
Для проектов, которым нужна **только цепь + контракты** (без mongo/pg/mono-инфры),
есть готовый docker-образ `dicoop/bootcoop:<branch>`. Он one-shot job'ом
накатывает на удалённую ноду все системные и кооперативные контракты,
активирует протокол-фичи, создаёт системный токен.
Минимальный `docker-compose.yaml` для стороннего репозитория:
```yaml
services:
node:
image: dicoop/blockchain:latest
ports:
- "8888:8888"
- "9876:9876"
volumes:
- ./blockchain-data:/mnt/dev/data
command: >
/bin/bash -c "/usr/local/bin/nodeos -d /mnt/dev/data
--config-dir /mnt/dev/config
--genesis-json /mnt/dev/config/genesis.json"
bootcoop:
image: dicoop/bootcoop:dev # или :testnet / :main / :latest
depends_on: [node]
restart: "no"
environment:
CHAIN_URL: http://node:8888
EOSIO_PUB_KEY: EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV
EOSIO_PRV_KEY: 5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3
RPC_WAIT_TIMEOUT_MS: "180000"
```
Запуск:
```bash
docker compose up -d node
docker compose up bootcoop # one-shot, выходит после успеха
```
Bootcoop ждёт `${CHAIN_URL}/v1/chain/get_info`, затем катит контракты на ноду.
По выходу с кодом 0 — цепь готова к работе.
### Версионирование
`dicoop/bootcoop:<tag>` всегда multi-stage'ится из `dicoop/contracts:<tag>` с
тем же `<tag>` (dev/testnet/main). Контракты и bootstrap синхронны по sha
коммита mono-репо — no skew.
Точный пин по коммиту: `dicoop/bootcoop:dev-<short-sha>` (тегается каждой сборкой).
### Дополнительные данные (опционально)
Если в стороннем проекте всё же нужны mono-данные (voskhod, ant, совет) —
выставить флаги, и поднять рядом mongo + postgres:
```yaml
environment:
CHAIN_URL: http://node:8888
MONGO_URI: mongodb://mongo:27017/cooperative-x
POSTGRES_HOST: postgres
POSTGRES_USERNAME: postgres
POSTGRES_PASSWORD: postgres!23!23
POSTGRES_DATABASE: voskhod
INSTALL_INITIAL_DATA: "1" # voskhod + ant + совет
INSTALL_EXTRA_DATA: "1" # + расширенный совет (5 пайщиков)
```
Без этих флагов запускается чистый chain-only сценарий.
## Тестирование
Перед запуском тестов необходима работающая инфраструктура (см. `docker-compose.yaml` в корне проекта):
-182
View File
@@ -1,182 +0,0 @@
# Sideboot — локальная нода + контракты в стороннем репозитории
Краткая инструкция для агентов в соседних репозиториях (parser2,
blockchain-protocol и т.п.), которым нужна чистая EOSIO-нода с
развёрнутыми кооперативными контрактами для интеграционных тестов.
**Без mono-инфры (mongo/postgres/desktop)**.
## Артефакты в DockerHub
| Образ | Назначение | Тег |
|---|---|---|
| `dicoop/blockchain` | EOSIO node (nodeos + cdt) | `latest` |
| `dicoop/contracts` | wasm/abi артефакт со всеми контрактами | `dev` / `testnet` / `main` / `latest` |
| `dicoop/bootcoop` | one-shot bootstrap (накатывает контракты на ноду) | `dev` / `testnet` / `main` / `latest` |
`dicoop/contracts` и `dicoop/bootcoop` синхронны по тегу — `dicoop/bootcoop:dev`
multi-stage из `dicoop/contracts:dev` (no skew).
`:latest` появляется только при сборке main-ветки. Для разработки
используй `:dev`.
## Минимальный docker-compose.yaml
```yaml
services:
node:
image: dicoop/blockchain:latest
ports:
- "8888:8888"
- "9876:9876"
volumes:
- ./blockchain-data:/mnt/dev/data
- ./node-config:/mnt/dev/config
command: >
/bin/bash -c "/usr/local/bin/nodeos -d /mnt/dev/data
--config-dir /mnt/dev/config
--genesis-json /mnt/dev/config/genesis.json"
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8888/v1/chain/get_info"]
interval: 2s
timeout: 2s
retries: 60
bootcoop:
image: dicoop/bootcoop:dev
depends_on:
node:
condition: service_started
restart: "no"
environment:
CHAIN_URL: http://node:8888
EOSIO_PUB_KEY: EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV
EOSIO_PRV_KEY: 5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3
RPC_WAIT_TIMEOUT_MS: "180000"
```
## Конфиги ноды
`./node-config/` должен содержать:
- `genesis.json`
- `config.ini`
- `protocol_features/` (директория с JSON-файлами фич)
Источник этих конфигов — `components/boot/src/configs/` в репо
`coopenomics/mono`. Скопируй их в свой репозиторий или подмонтируй
оттуда напрямую (если репо клонирован рядом):
```yaml
volumes:
- /path/to/mono/components/boot/src/configs:/mnt/dev/config
```
Это dev-конфигурация (single-node BPACCOUNT=eosio, ключи из
`.env-example` boot'а).
## Запуск
```bash
docker compose up -d node
docker compose up bootcoop # one-shot, выходит exit 0 после успеха
```
Время первого запуска: ~2-3 минуты (бутстрап накатывает 14+ контрактов
и создаёт 1000+ Registry-шаблонов).
После успеха цепь готова — все кооперативные аккаунты с задеплоенными
контрактами, выпущен токен AXON (eosio имеет 88000.0000 AXON свободно),
оператор `voskhod` получил powerup и стартовый transfer.
## Чистый перезапуск
Контракты накатываются на дев-цепь idempotent'но — повторный запуск
bootcoop без сброса данных может работать, но идеоматично сбрасывать
полностью. Так что:
```bash
# 1. Останови всё с удалением volumes/data
docker compose down -v
sudo rm -rf ./blockchain-data
# 2. Подними заново
docker compose up -d node
docker compose up bootcoop
```
Один-в-один как `pnpm run reboot:clean` в mono-репо, но без mono-инфры.
## Проверка что цепь готова
```bash
# Нода отвечает
curl -s http://localhost:8888/v1/chain/get_info | jq .head_block_num
# Контракты установлены (должны быть code_hash != 000...0)
for acct in eosio registrator soviet draft fund gateway capital wallet contributor marketplace meet branch loan apps; do
hash=$(curl -s -X POST http://localhost:8888/v1/chain/get_code -d "{\"account_name\":\"$acct\"}" | jq -r .code_hash)
echo "$acct: $hash"
done
```
Все hash'и должны быть не нулевыми (кроме сейчас намеренно отключенного
`ledger2` — он закомментирован в `components/contracts/CMakeLists.txt`,
вернётся когда раскомментируется).
## Опциональная mono-data
Если нужны mono-демо-данные (кооператив "voskhod", пайщик ant, совет —
требует `mongo` + `postgres` рядом):
```yaml
environment:
CHAIN_URL: http://node:8888
MONGO_URI: mongodb://mongo:27017/cooperative-x
POSTGRES_HOST: postgres
POSTGRES_USERNAME: postgres
POSTGRES_PASSWORD: postgres!23!23
POSTGRES_DATABASE: voskhod
INSTALL_INITIAL_DATA: "1" # voskhod + ant + совет (single chairman)
INSTALL_EXTRA_DATA: "1" # + расширенный совет (5 пайщиков)
```
Без этих флагов — чистый chain-only сценарий, ничего в БД не
записывается.
## Версионирование и пины
Точный пин на конкретный коммит mono:
```yaml
image: dicoop/bootcoop:dev-abc1234
```
Тегается каждой сборкой как `<branch>-<short-sha>`. Используй когда
нужна воспроизводимость (CI-тесты).
## Куда смотреть в коде
Если что-то в `bootcoop` идёт не так:
- `components/boot/src/index.ts` — CLI команды, ищи `boot:remote`
- `components/boot/src/init/infra.ts``startInfra()` — что и как деплоится
- `components/boot/src/configs/contracts.ts` — список контрактов и путей к wasm/abi
- `components/boot/src/configs/index.ts` — список аккаунтов, ключи, token spec
- `components/boot/Dockerfile` — как образ собирается (multi-stage)
- `.github/workflows/build-bootstrap.yaml` — CI workflow
- `.github/workflows/build-contracts.yaml` — workflow `dicoop/contracts`
- `components/contracts/CMakeLists.txt` — какие контракты собираются (`add_contract_build(<name>)`)
## Известные ограничения
- `boot:remote` логирует ошибки деплоя контрактов (`ENOENT: ledger2.wasm`)
но не падает — exit 0 даже если часть контрактов не установилась.
После запуска ОБЯЗАТЕЛЬНО проверь list контрактов через `get_code`,
не доверяй только exit-коду.
- `dicoop/contracts:dev` ребилдится только при изменении файлов в
`components/contracts/cpp/` или `CMakeLists.txt` (см. `paths:` в
`build-contracts.yaml`). Если контракт обновлён, но workflow не
тригернулся — `workflow_dispatch` руками.
- `dicoop/bootcoop:dev` ребилдится при изменении `components/boot/**`
ИЛИ `.github/workflows/build-bootstrap.yaml`. На обновление
контрактов нужен ручной rebuild bootcoop (или rerun workflow), чтобы
multi-stage подхватил свежий `dicoop/contracts:dev`.
-29
View File
@@ -1,41 +1,12 @@
import { defineBuildConfig } from 'unbuild'
// Bundle workspace-deps (cooptypes, @coopenomics/factory) внутрь
// dist/index.cjs, чтобы в docker-образе не нужно было их пробрасывать
// через workspace-context. NPM-deps оставляем external — nativе
// binaries (cpu-features через dockerode→ssh2) не bundling-friendly,
// плюс tree-shake даёт меньшие финальные node_modules.
//
// unbuild 2 имеет особенность: pkg.dependencies авто-добавляются в
// externals ДО hooks. Чтобы override — переопределяем external через
// rollup:options. Возвращаем false → не external → inline. Возвращаем
// undefined → unbuild решает по своим правилам (а он по умолчанию
// делает npm-deps external).
export default defineBuildConfig({
entries: [
'src/index',
],
declaration: true,
clean: true,
// Inline workspace-deps тянет в граф транзитивные npm-deps factory'а
// (mongodb, handlebars, ajv, ...). unbuild на них warning'ит как
// "implicit external" — они корректно остаются external (мы их
// мерджим в boot/package.json в Dockerfile), но warning по умолчанию
// делает failOnWarn:true → нужно явно отключить.
failOnWarn: false,
rollup: {
emitCJS: true,
},
hooks: {
'rollup:options'(_ctx, options) {
const prev = options.external
options.external = (id: string, importer?: string, isResolved?: boolean) => {
if (id === 'cooptypes' || id.startsWith('@coopenomics/factory'))
return false
if (typeof prev === 'function')
return (prev as any)(id, importer, isResolved)
return undefined
}
},
},
})
+1 -9
View File
@@ -1,20 +1,14 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.7.21-1",
"version": "2026.4.19-alpha-1",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "",
"keywords": [],
"main": "./dist/index.cjs",
"files": [
"dist",
"scripts"
],
"scripts": {
"build": "unbuild",
"enter": "./scripts/enter.sh",
"cli": "esno src/index.ts",
"deploy": "esno src/index.ts deploy",
@@ -23,10 +17,8 @@
"boot": "esno src/index.ts boot",
"boot:clean": "esno src/index.ts boot:clean",
"boot:extra": "esno src/index.ts boot:extra",
"boot:remote": "esno src/index.ts boot:remote",
"reboot:clean": "cd components/boot/scripts && bash clean_reboot.sh",
"reboot:extra": "cd components/boot/scripts && bash extra_reboot.sh",
"reboot:blago": "cd components/boot/scripts && bash blago_reboot.sh",
"create-coop": "esno src/index.ts create-coop",
"clear": "./scripts/clear.sh",
"start": "./scripts/restart.sh",
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
# Полный перезапуск dev-среды для теста Благороста одной командой:
# 1) extra_reboot.sh — чистая цепь + кооператив + совет 5 человек +
# dev-shortcut онбординга capital (extensions.capital: все *_done=true);
# 2) ожидание готовности controller (GraphQL отвечает);
# 3) seed-capital до фазы $SEED_UP_TO (default 04-contributor): программы
# УХД/Благорост + setconfig, проекты, регистрация ant как участника.
#
# Глубину сида можно менять: SEED_UP_TO=08-investments pnpm run reboot:blago
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env"
set +a
fi
bash "$SCRIPT_DIR/extra_reboot.sh"
# Per-instance адрес controller'а — из корневого .env (API_URL); coopback после
# force-recreate поднимается десятки секунд, seed-фазы без него падают.
GRAPHQL_URL="${API_URL:-http://127.0.0.1:2998/v1/graphql}"
echo "Ждём готовности controller ($GRAPHQL_URL)..."
until curl -s --max-time 3 -o /dev/null -X POST -H 'Content-Type: application/json' \
--data '{"query":"{__typename}"}' "$GRAPHQL_URL"; do
sleep 3
done
echo "Controller готов."
UP_TO="${SEED_UP_TO:-04-contributor}"
echo "Сидируем capital до фазы $UP_TO..."
cd "$SCRIPT_DIR/.."
CONTROLLER_GRAPHQL_URL="$GRAPHQL_URL" pnpm exec esno src/scripts/seed-capital/index.ts --up-to="$UP_TO"
echo "Готово: среда Благорост поднята (seed до $UP_TO)."
+13 -22
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
# Загружаем per-instance конфиг из корня репо.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -10,34 +9,26 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем и удаляем контейнеры (сеть НЕ трогаем — к ней может быть
# подключён внешний контейнер вроде provider-backend; down её снёс бы с ошибкой).
echo "Останавливаем и удаляем контейнеры..."
docker compose rm -fsv mongo postgres monoredis cooparser coopback || true
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres cooparser || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем тома баз данных для чистого старта (named volumes rm -v не чистит)
echo "Удаляем тома баз данных..."
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}"
docker volume rm "${PROJECT}_postgres_data" "${PROJECT}_mongo_data" 2>/dev/null || true
# Удаляем blockchain data через контейнер (alpine под root стирает данные
# независимо от владельца — без sudo на любой ноде; на проде nodeos пишет под root).
# Удаляем blockchain data
echo "Удаляем blockchain data..."
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
sudo rm -rf ../blockchain-data/
# Пересоздаем и запускаем базы данных + Redis (monoredis).
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash.
# Пересоздаем и запускаем базы данных
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres monoredis
docker compose up -d mongo postgres
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
# Ждем готовности MongoDB
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -51,7 +42,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
done
echo "PostgreSQL готов!"
# Запускаем boot процесс (clean: только программы Благорост/маркетплейс)
# Запускаем boot процесс
echo "Запускаем boot процесс..."
pnpm run boot:clean
@@ -60,6 +51,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose up -d --force-recreate coopback || true
docker compose restart coopback || true
echo "Перезапуск завершен!"
+1 -1
View File
@@ -1,4 +1,4 @@
docker run --rm -it --name cdt \
--volume $(pwd)/:/project \
-w /project \
dicoop/blockchain:latest /bin/bash
dicoop/blockchain_v5.1.1:dev /bin/bash
+14 -17
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
# Загружаем per-instance конфиг из корня репо.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -10,32 +9,30 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем контроллер перед очисткой данных
echo "Останавливаем контроллер..."
docker compose down coopback || true
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres monoredis cooparser coopback || true
docker compose down -v mongo postgres cooparser || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем blockchain data.
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
# данные под root). Единый способ с reboot.sh / clean_reboot.sh.
# Удаляем blockchain data
echo "Удаляем blockchain data..."
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
sudo rm -rf ../blockchain-data/
# Пересоздаем и запускаем базы данных + Redis (monoredis).
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash,
# и провайдер не может взять org-данные partner1 (PROVIDER_URL=coopback:2998).
# Пересоздаем и запускаем базы данных
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres monoredis
docker compose up -d mongo postgres
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
# Ждем готовности MongoDB
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -49,7 +46,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
done
echo "PostgreSQL готов!"
# Запускаем boot процесс (расширенный: совет + пайщики; partner1 — при EXTRA_RENT=1)
# Запускаем boot процесс
echo "Запускаем boot процесс..."
pnpm run boot:extra
@@ -58,6 +55,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose up -d --force-recreate coopback || true
docker compose up -d coopback
echo "Перезапуск завершен!"
+1 -1
View File
@@ -26,7 +26,7 @@ docker run --name node -d -p 8888:8888 -p 9876:9876 -p 8080:8080 \
-v ~/testnet/config:/mnt/dev/config \
-v ~/testnet/wallet:/root/eosio-wallet \
-v $dir/../../contracts:/contracts \
dicoop/blockchain:latest \
dicoop/blockchain:v5.1.0-dev \
/bin/bash -c '/usr/local/bin/nodeos -d /mnt/dev/data -p eosio --config-dir /mnt/dev/config --genesis-json /mnt/dev/config/genesis.json'
docker network connect hyperion node
+6 -6
View File
@@ -12,7 +12,7 @@ fi
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres monoredis cooparser coopback || true
docker compose down -v mongo postgres opensearch cooparser || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
@@ -21,15 +21,15 @@ docker compose stop node || true
# Удаляем blockchain data
echo "Удаляем blockchain data..."
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
sudo rm -rf ../blockchain-data/
# Пересоздаем и запускаем базы данных
echo "Пересоздаем и запускаем базы данных..."
docker compose up -d mongo postgres monoredis
docker compose up -d mongo postgres opensearch
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
# Ждем готовности MongoDB
echo "Ждем готовности MongoDB..."
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
echo "MongoDB еще не готов, ждем..."
sleep 2
done
@@ -52,6 +52,6 @@ echo "Запускаем parser..."
docker compose up -d cooparser
echo "Запускаем контроллер..."
docker compose up -d --force-recreate coopback || true
docker compose restart coopback || true
echo "Перезапуск завершен!"
+24 -56
View File
@@ -229,7 +229,7 @@ export default class Blockchain {
// console.log(data)
// console.log("abi: ", serializedAbiHexString)
await this.api.transact(
this.api.transact(
{
actions: [
{
@@ -267,70 +267,38 @@ export default class Blockchain {
console.log('contract setted: ', contract.target)
}
catch (e) {
// Отсутствующий wasm/abi — soft warn. Контракт может быть
// временно отключён в components/contracts/CMakeLists.txt на
// другой ветке (например ledger2 пока в reports), и dicoop/contracts
// его не содержит. Это нормально для in-progress feature-веток.
if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') {
console.warn(
`[setContract] SKIPPED '${contract.name}' on '${contract.target}': artifact missing at ${contract.path}`,
)
return
}
// Всё остальное (RPC errors, transaction failures, abi parse) —
// fail-fast: sideboot не должен молча отдавать "готовую" цепь
// с дырами в развёртывании.
const msg = e instanceof Error ? e.message : String(e)
throw new Error(
`Failed to set contract '${contract.name}' on '${contract.target}' (path: ${contract.path}): ${msg}`,
{ cause: e },
)
console.log(e)
}
}
async activateFeature(feature: Feature) {
await this.update_pass_instance()
try {
await this.api.transact(
{
actions: [
{
account: 'eosio',
name: 'activate',
authorization: [
{
actor: 'eosio',
permission: 'active',
},
],
data: {
feature_digest: feature.hash,
await this.api.transact(
{
actions: [
{
account: 'eosio',
name: 'activate',
authorization: [
{
actor: 'eosio',
permission: 'active',
},
],
data: {
feature_digest: feature.hash,
},
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
},
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
console.log('Фича активирована: ', feature.name)
}
catch (e: any) {
// На уже забутстрапленном (не обнулённом) чейне протокол-фича уже активна —
// nodeos отдаёт protocol_feature_exception (code 3250000). Это не повод
// ронять весь boot: фича на месте, нужный результат достигнут, продолжаем.
const errName = e?.json?.error?.name
const errCode = e?.json?.error?.code
const msg = e instanceof Error ? e.message : String(e)
if (errName === 'protocol_feature_exception' || errCode === 3250000 || /already activated/i.test(msg)) {
console.warn(`Фича уже активирована, пропускаю: ${feature.name}`)
return
}
throw e
}
console.log('Фича активирована: ', feature.name)
}
async createToken(params: TokenContract.Interfaces.ICreate) {
+20 -48
View File
@@ -4,133 +4,105 @@ import { config } from 'dotenv'
config()
/**
* Резолвер пути до wasm/abi пары контракта.
*
* Layout:
* - **CONTRACTS_DIR не задан** (локальная сборка через `components/contracts/build*.sh`)
* — `eosio.*` лежат в `system/contracts/<name>/`, остальные в `<name>/`.
* - **CONTRACTS_DIR задан** (образ `dicoop/contracts`, mounted в bootstrap-контейнере)
* — flat layout: все контракты в `<CONTRACTS_DIR>/<name>/<name>.{wasm,abi}`,
* включая `eosio.*` (см. `.github/workflows/build-contracts.yaml` — system-контракты
* распакованы из `system/contracts/eosio.*` в плоский корень при упаковке).
*
* Это позволяет одному и тому же `boot:remote` работать и из локального workspace,
* и из bootstrap-образа, без дублирования конфигов.
*/
const CONTRACTS_DIR = process.env.CONTRACTS_DIR
const userBase = CONTRACTS_DIR ?? path.resolve(process.cwd(), '../contracts/build/contracts')
const systemBase = CONTRACTS_DIR ?? path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts')
export default [
{
name: 'eosio.boot',
path: path.join(systemBase, 'eosio.boot'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.boot'),
target: 'eosio',
},
{
name: 'eosio.system',
path: path.join(systemBase, 'eosio.system'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.system'),
target: 'eosio',
},
{
name: 'eosio.token',
path: path.join(systemBase, 'eosio.token'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.token'),
target: 'eosio.token',
},
{
name: 'eosio.msig',
path: path.join(systemBase, 'eosio.msig'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.msig'),
target: 'eosio.msig',
},
{
name: 'eosio.wrap',
path: path.join(systemBase, 'eosio.wrap'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.wrap'),
target: 'eosio.wrap',
},
{
name: 'registrator',
path: path.join(userBase, 'registrator'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/registrator'),
target: 'registrator',
},
{
name: 'soviet',
path: path.join(userBase, 'soviet'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/soviet'),
target: 'soviet',
},
{
name: 'marketplace',
path: path.join(userBase, 'marketplace'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/marketplace'),
target: 'marketplace',
},
{
name: 'draft',
path: path.join(userBase, 'draft'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/draft'),
target: 'draft',
},
{
name: 'branch',
path: path.join(userBase, 'branch'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/branch'),
target: 'branch',
},
{
name: 'gateway',
path: path.join(userBase, 'gateway'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/gateway'),
target: 'gateway',
},
{
name: 'fund',
path: path.join(userBase, 'fund'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/fund'),
target: 'fund',
},
{
name: 'ledger',
path: path.join(userBase, 'ledger'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/ledger'),
target: 'ledger',
},
{
name: 'ledger2',
path: path.join(userBase, 'ledger2'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/ledger2'),
target: 'ledger2',
},
{
name: 'test',
path: path.join(userBase, 'test'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/test'),
target: 'test',
},
{
name: 'contributor',
path: path.join(userBase, 'contributor'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/contributor'),
target: 'contributor',
},
{
name: 'capital',
path: path.join(userBase, 'capital'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/capital'),
target: 'capital',
},
{
name: 'wallet',
path: path.join(userBase, 'wallet'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/wallet'),
target: 'wallet',
},
{
name: 'loan',
path: path.join(userBase, 'loan'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/loan'),
target: 'loan',
},
{
name: 'meet',
path: path.join(userBase, 'meet'),
path: path.resolve(process.cwd(), '../contracts/build/contracts/meet'),
target: 'meet',
},
{
name: 'apps',
path: path.join(userBase, 'apps'),
target: 'apps',
},
{
name: 'expense',
path: path.join(userBase, 'expense'),
target: 'expense',
},
]
-8
View File
@@ -150,14 +150,6 @@ export default {
name: 'contributor',
code_permissions_to: ['contributor'],
},
{
name: 'apps',
code_permissions_to: ['apps'],
},
{
name: 'expense',
code_permissions_to: ['expense'],
},
// {
// name: provider_chairman,
// },
@@ -1,11 +0,0 @@
{
"protocol_feature_type": "builtin",
"dependencies": [],
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
}
@@ -1,33 +0,0 @@
{
"protocol_feature_type": "builtin",
"dependencies": [
"09e86cb0accf8d81c9e85d34bea4b925ae936626d00c984e4691186891f5bc16",
"1a99a59d87e06e09ec5b028a9cbb7749b4a5ad8819004365d02dc4379a8b7241",
"2652f5f96006294109b3dd0bbde63693f55324af452b799ee137a81a905eed25",
"299dcb6af692324b899b39f16d5a530a33062804e41f09dc97e9f156b4476707",
"35c2186cc36f7bb4aeaf4487b36e57039ccf45a9136aa856a5d569ecca55ef2b",
"4a90c00d55454dc5b059055ca213579c6ea856967712a56017487886a4d4cc0f",
"4e7bf348da00a945489b2a681749eb56f5de00b900014e137ddae39f48f69d67",
"4fca8bd82bbd181e714e283f83e1b45d95ca5af40fb89ad3977b653c448f78c2",
"5443fcf88330c586bc0e5f3dee10e7f63c76c00249c87fe4fbf7f38c082006b4",
"63320dd4a58212e4d32d1f58926b73ca33a247326c2a5e9fd39268d2384e011a",
"68dcaa34c0517d19666e6b33add67351d8c5f69e999ca1e37931bc410a297428",
"6bcb40a24e49c26d0a60513b6aeb8551d264e4717f306b81a37a5afb3b47cedc",
"8ba52fe7a3956c5cd3a656a3174b931d3bb2abb45578befc59f283ecd816a405",
"ad9e3d8f650687709fd68f4b90b41f7d825a365b02c23a636cef88ac2ac00c43",
"bcd2a26394b36614fd4894241d3c451ab0f6fd110958c3423073621a70826e99",
"c3a6138c5061cf291310887c0b5c71fcaffeab90d5deb50d3b9e687cead45071",
"d528b9f6e9693f45ed277af93474fd473ce7d831dae2180cca35d907bd10cb40",
"e0fb64b1085cc5538970158d05a009c24e276fb94e1a0bf6a528b48fbc4ff526",
"ef43112c6543b88db2283a2e077278c315ae2c84719a8b25f25cc88565fbea99",
"f0af56d2c5a48d60a4a5b5c903edfb7db3a736a94ed589d0b797df33ff9d3e1d",
"fce57d2331667353a0eac6b4209b67b843a7262a848af0a49a6e2fa9f6584eb4"
],
"description_digest": "01a8bebb4922c38293a8909668f7d00ba4a21a455c69719ad7dcc33ea1e631c0",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "SAVANNA"
}
-13
View File
@@ -15,16 +15,3 @@ export async function runContainer(): Promise<void> {
})
})
}
export async function runInfraContainers(): Promise<void> {
return new Promise((resolve, reject) => {
exec('docker compose up -d mongo postgres', (error, stdout, stderr) => {
if (error) {
console.error(`Ошибка при запуске инфраструктурных контейнеров: ${error.message}`)
reject(error)
return
}
resolve()
})
})
}
+1 -95
View File
@@ -5,14 +5,13 @@ import { Command } from 'commander'
import { config } from 'dotenv'
import { execCommand } from './docker/exec'
import { stopContainerByName } from './docker/stop'
import { runContainer, runInfraContainers } from './docker/run'
import { runContainer } from './docker/run'
import { boot, bootClean, bootExtra } from './init/booter'
import { startCoop } from './init/cooperative'
import { sleep } from './utils'
import { checkHealth } from './docker/health'
import { clearDB, clearDirectory, deleteFile } from './docker/purge'
import { deployCommand } from './docker/deploy'
import { addTestUser } from './scripts/add-test-user'
config()
@@ -31,20 +30,6 @@ const program = new Command()
program.version('0.1.0')
// Epic 4: smoke-скрипт для проверки ProcessRegistry end-to-end.
program
.command('add-test-user <username>')
.description('Добавить пайщика через registrator::adduser — для live-тестов ProcessRegistry')
.action(async (username: string) => {
try {
await addTestUser(username)
process.exit(0)
} catch (e) {
console.error('Failed:', e)
process.exit(1)
}
})
// Команда для запуска команды в контейнере
program
.command('cleos [cmd...]')
@@ -136,7 +121,6 @@ program
}
try {
await runInfraContainers()
await runContainer()
await sleep(5000)
@@ -215,7 +199,6 @@ program
}
try {
await runInfraContainers()
await runContainer()
await sleep(5000)
@@ -278,7 +261,6 @@ program
await stopContainerByName('node')
await deleteFile(keosdPath)
await clearDirectory(basePath)
await runInfraContainers()
await sleep(5000)
await clearDB()
await runContainer()
@@ -304,82 +286,6 @@ program
}
})
/**
* Bootstrap режим для удалённой ноды (без управления docker-контейнером).
*
* Используется внутри образа `dicoop/bootstrap` (one-shot job
* в docker-compose), запускаемого рядом с уже работающим `ke-node`-сервисом.
* RPC URL берётся из `CHAIN_URL` (тот же конфиг, что у обычного `boot`),
* пути до wasm/abi — из `CONTRACTS_DIR` (см. `configs/contracts.ts`).
*
* Поведение:
* 1. Ждёт готовности RPC по `${CHAIN_URL}/v1/chain/get_info` (timeout
* `RPC_WAIT_TIMEOUT_MS`, default 120 c).
* 2. Запускает `startInfra()` — деплоит все контракты, активирует фичи,
* создаёт токен, инициализирует системные параметры.
* 3. Опционально — initial-data (`INSTALL_INITIAL_DATA=1`, требует MONGO/PG)
* и extra-data (`INSTALL_EXTRA_DATA=1`).
*
* Не пытается запускать/останавливать docker-контейнеры — нода поднимается
* отдельным сервисом docker-compose, bootstrap только катит на неё контракты.
*/
program
.command('boot:remote')
.description('Bootstrap protocol against an externally running KE node (no docker)')
.action(async () => {
const config = (await import('./configs')).default
const { startInfra, installInitialData, installExtraData } = await import('./init/infra')
const url = `${config.network.protocol}://${config.network.host}${config.network.port}`
const timeoutMs = Number(process.env.RPC_WAIT_TIMEOUT_MS ?? 120000)
console.log(`Waiting for RPC at ${url} (timeout ${timeoutMs}ms)...`)
const deadline = Date.now() + timeoutMs
let ready = false
while (Date.now() < deadline) {
try {
const r = await fetch(`${url}/v1/chain/get_info`, {
signal: AbortSignal.timeout(2000),
} as RequestInit)
if (r.ok) {
ready = true
break
}
}
catch {
// RPC ещё не готов — пробуем снова
}
await sleep(1000)
}
if (!ready) {
console.error(`RPC ${url} not ready within ${timeoutMs}ms`)
process.exit(1)
}
console.log(`RPC ready at ${url}`)
try {
const blockchain = await startInfra()
console.log('startInfra: done')
if (process.env.INSTALL_INITIAL_DATA === '1') {
console.log('installInitialData: start')
await installInitialData(blockchain, false)
}
if (process.env.INSTALL_EXTRA_DATA === '1') {
console.log('installInitialData (extended): start')
await installInitialData(blockchain, true)
await installExtraData(blockchain)
}
console.log('boot:remote completed')
process.exit(0)
}
catch (e) {
console.error('boot:remote failed:', e)
process.exit(1)
}
})
program.parse(process.argv) // Пуск парсинга аргументов
async function gracefulShutdown() {
+9 -19
View File
@@ -1,6 +1,7 @@
import config from '../configs'
import { initExtensionsInPostgres, initSystemStatus } from '../postgres-init'
import { installExtraData, installInitialData, startInfra } from './infra'
import { startCoop } from './cooperative'
import { CooperativeClass, startCoop } from './cooperative'
export async function boot() {
const blockchain = await startInfra()
@@ -13,29 +14,18 @@ export async function boot() {
}
export async function bootClean() {
// Только инфраструктура: контракты, фичи, токен, системные параметры.
// Ни совета, ни программ — программы (createPrograms) требуют существующий
// совет и создаются в boot/bootExtra внутри installInitialData. В clean их
// не делаем (иначе soviet::createprog падает с «Совет не найден»).
await startInfra()
const blockchain = await startInfra()
console.log('Создаём программы (Благорост и маркетплейс)')
const cooperative = new CooperativeClass(blockchain)
await cooperative.createPrograms(config.provider)
}
export async function bootExtra() {
const blockchain = await startInfra()
await installInitialData(blockchain, true) // Создать расширенный совет (устанавливает статус 'active' в MongoDB)
// installExtraData регистрирует partner1 как coop с auto-approve от провайдера —
// это и есть программная on-chain активация, которая триггерит аренду VM в
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
// сервера (например, просто пересев совета/чейна для других задач), поэтому
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
if (process.env.EXTRA_RENT === '1') {
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
await installExtraData(blockchain) // Регистрируем partner1 как coop (active)
}
else {
console.log('EXTRA_RENT не задан → пропускаем провижининг partner1 (аренда не запускается)')
}
await installExtraData(blockchain) // Добавить дополнительных пайщиков
console.log('Инициализируем статус системы в PostgreSQL')
await initSystemStatus() // Устанавливает статус 'active' в PostgreSQL
+9 -15
View File
@@ -4,8 +4,6 @@ import config, { GOVERN_SYMBOL, SYMBOL } from '../configs'
import Blockchain from '../blockchain'
import { generateRandomSHA256 } from '../utils/randomHash'
import { processLastDecision } from '../tests/soviet/processLastDecision'
import { signProgramAgreement } from './sign-program-agreement'
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
const test_hash
= '157192b276da23cc84ab078fc8755c051c5f0430bf4802e55718221e6b76c777'
@@ -76,7 +74,7 @@ export class CooperativeClass {
await this.blockchain.createProgram({
coopname,
username: coopname,
type: 'capital',
type: 'blagorost',
title: 'Целевая потребительская программа "Благорост"',
announce: '',
description: '',
@@ -158,17 +156,13 @@ export class CooperativeClass {
console.log('Отправляем подписанное положение о ЦПП Кошелька оператору')
// После Эпика 2 / компонента 48 soviet::sndagreement отказывается на
// program_id > 0; программные соглашения подписываются через
// wallet::signagree (auth: coopname@active).
await signProgramAgreement(
this.blockchain,
config.provider,
username!,
walletProgramId,
walletDraftId,
await this.blockchain.sendAgreement({
coopname: config.provider,
administrator: config.provider,
username: username!,
agreement_type: 'wallet',
document,
)
})
console.log('Переводим аккаунт в кооператив')
@@ -178,8 +172,8 @@ export class CooperativeClass {
params: {
is_cooperative: true,
coop_type: 'conscoop',
announce: 'voskhod-dev.coopenomics.world',
description: 'Восход — тестовый кооператив-оператор (dev-стенд)',
announce: 'Тестовый кооператив',
description: 'Тестовое описание',
initial: `100.0000 ${config.token.govern_symbol}`,
minimum: `300.0000 ${config.token.govern_symbol}`,
org_initial: `1000.0000 ${config.token.govern_symbol}`,
+5 -200
View File
@@ -1,9 +1,5 @@
import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import axios from 'axios'
import ecc from 'eosjs-ecc'
import { Generator, Registry } from '@coopenomics/factory'
import type { Cooperative } from 'cooptypes'
import { DraftContract } from 'cooptypes'
@@ -12,15 +8,9 @@ import type { Account, Contract } from '../types'
import config from '../configs'
import Blockchain from '../blockchain'
import { sleep } from '../utils'
import { generateRandomSHA256 } from '../utils/randomHash'
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
import { CooperativeClass } from './cooperative'
import { signProgramAgreement } from './sign-program-agreement'
import { fakeDocument } from '../tests/shared/fakeDocument'
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
import { generateRandomSHA256 } from '../utils/randomHash'
export async function startInfra() {
// инициализируем инстанс с ключами
@@ -80,10 +70,7 @@ export async function startInfra() {
for (const contract of filtered_contracts)
await blockchain.setContract(contract)
// 2s часто не хватает на свежем nodeos: setabi последнего контракта
// ещё не финализирован, eosio.token::create падает с
// "fetching abi for eosio.token: Read past end of buffer".
await sleep(8000)
await sleep(2000)
console.log('создаём токен')
await blockchain.createToken({
@@ -581,189 +568,7 @@ export async function installInitialData(blockchain: Blockchain, isExtended = fa
}
export async function installExtraData(blockchain: Blockchain) {
// Регистрируем partner1 как coop с авто-approve от провайдера (Восхода).
// Без этого реальный UI flow требует ручной активации председателем —
// провайдер не подтянет partner1 в свою DB пока в registrator.coops его
// нет. На dev-стенде хотим иметь готовый partner1 active сразу после
// `reboot:extra`, чтобы прыгать из 0 → 8 (Hostkey rent → install → ACTIVE)
// одним нажатием.
//
// Упрощённый flow (без sendAgreement через soviet — он отказывается на
// program_id > 0 с проверкой «подписывается через wallet::signagree»).
// Минимум для попадания в registrator.coops со status=active:
// newaccount → reguser(type=organization) → regcoop → stcoopstatus(active)
console.log('\n=== installExtraData: регистрируем partner1 как coop (active) ===')
const username = 'partner1'
// Берём WIF из docs-harness/state/cooperatives/partner1.json, если он там есть.
// Без этого boot:extra на каждом запуске генерирует случайный privateKey,
// а harness 08 (chairman install wizard /partner1/install) подаёт WIF из
// фикстуры на шаге 1 — они расходятся, startInstall mutation не принимает
// ключ, wizard зависает на шаге 1 без видимой ошибки. На репозиторий
// фикстура коммит'ится разработчиком вручную; здесь только читаем.
let seededKeys: { privateKey: string; publicKey: string } | undefined
try {
const fixturePath = resolve(
__dirname,
'../../../docs-harness/state/cooperatives/partner1.json',
)
if (existsSync(fixturePath)) {
const fx = JSON.parse(readFileSync(fixturePath, 'utf-8'))
if (fx?.wif) {
// publicKey не хранится в фикстуре — деривим из приватного.
const pub = fx.publicKey || await ecc.privateToPublic(fx.wif)
seededKeys = { privateKey: fx.wif, publicKey: pub }
console.log(`✓ partner1: используем seeded WIF из фикстуры (pub=${pub.slice(0, 12)}…)`)
}
}
} catch (e: any) {
console.warn(`⚠ partner1 fixture read failed (${e.message}) — генерируем новый ключ`)
}
const account = await blockchain.generateKeypair(username, seededKeys, 'Аккаунт partner1')
await blockchain.createAccount({
coopname: config.provider,
referer: '',
username: account.username,
public_key: account.publicKey,
meta: '',
})
const registration_hash = generateRandomSHA256()
await blockchain.registerUser({
coopname: config.provider,
braname: '',
username: account.username,
type: 'organization',
statement: {
hash: registration_hash,
signatures: [],
meta: '{}',
version: '1.0.0',
doc_hash: registration_hash,
meta_hash: registration_hash,
} as any,
registration_hash,
})
await blockchain.registerCooperative({
username: account.username,
coopname: account.username,
params: {
is_cooperative: true,
coop_type: 'conscoop',
announce: 'partner-dev.coopenomics.world',
description: 'Партнёрский кооператив (тестовый dev-стенд)',
initial: `100.0000 ${config.token.govern_symbol}`,
minimum: `300.0000 ${config.token.govern_symbol}`,
org_initial: `1000.0000 ${config.token.govern_symbol}`,
org_minimum: `3000.0000 ${config.token.govern_symbol}`,
},
document: {
hash: registration_hash,
signatures: [],
meta: '{}',
version: '1.0.0',
doc_hash: registration_hash,
meta_hash: registration_hash,
} as any,
})
await blockchain.preInit({
coopname: account.username,
username: config.provider,
status: 'active',
})
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
// перехода в ACTIVE, см. provider CLAUDE.md §13) падает ассертом
// eosio.token::is_can_transfer «Получатель не является участником целевой
// потребительской программы кошелька» — и инстанс навсегда застревает в
// INSTALL. wallet::signagree (auth voskhod@active) делает partner1 членом
// ЦПП Кошелька. Та же механика, что для обычных пайщиков в participant.ts.
console.log('Подписываем wallet-соглашение за partner1 (членство в ЦПП Кошелька)')
await signProgramAgreement(blockchain, config.provider, account.username, walletProgramId, walletDraftId, fakeDocument)
// Ресурсы для партнёра: powerup CPU/NET.
await blockchain.powerup({
payer: 'eosio',
receiver: account.username,
days: config.powerup.days,
payment: `100.0000 ${config.token.symbol}`,
transfer: true,
})
// === Засев partner1 в воскход-coopback (Mongo organizations + paymentMethods + Postgres users) ===
//
// Provider при initSystem-инициализации partner1 запрашивает у воскход-coopback'а
// приватные данные организации через GraphQL `getAccount(partner1).private_account`.
// Resolver идёт по ветке `username != config.coopname` → userRepository.findByUsername
// (Postgres users), затем organizationRepository.findByUsername (Mongo organizations).
// Без этих записей resolver вернёт private_account=null → провайдер упадёт
// «Unsupported account type: undefined» при подготовке organization_data.
console.log('Засеваем partner1 как пайщика-organization в воскход-coopback')
const partnerEmail = `chairman.${account.username}@example.com`
const generator = new Generator()
await generator.connect(process.env.MONGO_URI as string)
await generator.save('organization', {
username: account.username,
type: 'coop',
short_name: 'ПК "Партнёр-1"',
full_name: 'Потребительский Кооператив "Партнёр-1"',
represented_by: {
first_name: 'Иван',
last_name: 'Иванов',
middle_name: 'Иванович',
position: 'Председатель',
based_on: 'Решение общего собрания №1',
},
country: 'Российская Федерация',
city: 'Москва',
fact_address: '117593 г. Москва, ул. Тестовая, дом 1',
full_address: '117593 г. Москва, ул. Тестовая, дом 1',
email: partnerEmail,
phone: '+71234567891',
details: {
inn: '7728130612',
ogrn: '1247700283347',
kpp: '772801002',
},
} as any)
await generator.save('paymentMethod', {
is_default: true,
method_id: randomUUID(),
method_type: 'bank_transfer',
username: account.username,
data: {
account_number: '40703810038000110118',
currency: 'RUB',
card_number: '',
bank_name: 'ПАО Сбербанк',
details: {
bik: '044525225',
corr: '30101810400000000225',
kpp: '772801002',
},
},
})
await mongoose.disconnect().catch(() => {})
await initUsersInPostgres([{
username: account.username,
email: partnerEmail,
type: 'organization',
role: 'user',
status: 'joined',
is_registered: true,
}])
console.log(`=== partner1 (${account.username}) засеян в воскход-coopback (Mongo+Postgres) ===\n`)
// В расширенном режиме пайщики уже добавлены в installInitialData
// Здесь можно добавить дополнительную логику инициализации если потребуется
console.log('Дополнительная инициализация для расширенного режима выполнена')
}
+14 -19
View File
@@ -1,11 +1,12 @@
import config from '../configs'
import axios from 'axios'
import { describe, expect, it } from 'vitest'
import { Registry } from '@coopenomics/factory'
import { RegistratorContract, Cooperative as TCooperative } from 'cooptypes'
import type { Account, Contract, Keys } from '../types'
import config, { GOVERN_SYMBOL, SYMBOL } from '../configs'
import Blockchain from '../blockchain'
import { generateRandomSHA256 } from '../utils/randomHash'
import { sendPostToCoopbackWithSecret, sleep } from '../utils'
import { fakeDocument } from '../tests/shared/fakeDocument'
import { signProgramAgreement } from './sign-program-agreement'
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
import { RegistratorContract } from 'cooptypes'
import type { Keys } from '../types'
export class ParticipantsClass {
public blockchain: Blockchain
@@ -32,7 +33,6 @@ export class ParticipantsClass {
minimum: '100.0000 RUB',
spread_initial: false,
meta: '',
registration_hash: generateRandomSHA256(),
}
await this.blockchain.api.transact({
@@ -113,7 +113,6 @@ export class ParticipantsClass {
minimum: '100.0000 RUB',
spread_initial: false,
meta: '',
registration_hash: generateRandomSHA256(),
}
await this.blockchain.api.transact({
@@ -155,17 +154,13 @@ export class ParticipantsClass {
console.log('Отправляем подписанное положение о ЦПП Кошелька оператору')
// После Эпика 2 / компонента 48 soviet::sndagreement отказывается на
// program_id > 0; программные соглашения подписываются через
// wallet::signagree (auth: coopname@active).
await signProgramAgreement(
this.blockchain,
config.provider,
username!,
walletProgramId,
walletDraftId,
fakeDocument,
)
await this.blockchain.sendAgreement({
coopname: config.provider,
administrator: config.provider,
username: username!,
agreement_type: 'wallet',
document: fakeDocument,
})
console.log('создаём кошелёк')
@@ -1,99 +0,0 @@
import { SovietContract, WalletContract } from 'cooptypes'
import { getTotalRamUsage } from '../utils/getTotalRamUsage'
async function getCoopProgramWallet(blockchain: any, coopname: string, program_id: number) {
const program = await blockchain.getTableRows(
SovietContract.contractName.production,
coopname,
'programs',
1000,
program_id.toString(),
program_id.toString(),
)
return program[0]
}
interface UserProgramAgreement {
program_id: number | string
doc_hash: string
version: number | string
draft_id: number | string
signed_at: string
}
interface WalletUserRecord {
username: string
programs: UserProgramAgreement[]
}
// Подписание программного соглашения через `wallet::signagree` (ADR-008, Эпик 2).
// После компонента 48 `soviet::sndagreement` отказывается на program_id > 0 —
// программные соглашения переехали в контракт wallet, источник правды
// «участник ЦПП X» = `wallet::users[username].programs[]`.
//
// Auth: coopname@active (или любой системный контракт из contracts_whitelist
// контракта wallet). В boot-flow ключ кооператива всегда есть в keychain,
// подписываем coopname'ом.
//
// Живёт в init/, а не в tests/, чтобы не тянуть vitest в production-цепочке
// `boot → init/cooperative → init/participant`. Тесты реэкспортируют отсюда.
export async function signProgramAgreement(
blockchain: any,
coopname: string,
username: string,
program_id: number,
draft_id: number,
document: any,
) {
const data: WalletContract.Actions.SignAgreement.ISignAgreement = {
coopname,
username,
program_id,
document,
draft_id,
}
const result = await blockchain.api.transact(
{
actions: [
{
account: WalletContract.contractName.production,
name: WalletContract.Actions.SignAgreement.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data,
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
getTotalRamUsage(result)
if (!result.transaction_id)
throw new Error('wallet::signagree не вернул transaction_id')
const userRows = await blockchain.getTableRows(
WalletContract.contractName.production,
coopname,
WalletContract.Tables.Users.tableName,
1000,
) as WalletUserRecord[]
const userRecord = userRows.find(r => r.username === username)
if (!userRecord)
throw new Error(`wallet::users[${coopname}, ${username}] должен быть создан после signagree`)
const signedProgram = userRecord.programs.find(p => Number(p.program_id) === program_id)
if (!signedProgram)
throw new Error(`wallet::users[${username}].programs[] должен содержать program_id=${program_id}`)
const program = await getCoopProgramWallet(blockchain, coopname, program_id)
if (!program)
throw new Error(`soviet::programs[${coopname}, ${program_id}] должна существовать`)
return {
txId: result.transaction_id,
wallet: { username, ...signedProgram },
program,
}
}
-9
View File
@@ -285,15 +285,6 @@ export async function initExtensionsInPostgres() {
new Date('2026-02-09T02:27:57.155Z')
])
// reports — встроенный extension, конфиг пустой (BuiltinSchema).
// Контроллер тоже добивает запись через installDefaultApps на старте,
// но сеем тут заранее, чтобы стол появился сразу после boot.
await client.query(`
INSERT INTO "extensions" (name, enabled, config, schema_version)
VALUES ($1, $2, $3, $4)
ON CONFLICT (name) DO NOTHING
`, ['reports', true, '{}', 1])
console.log('Extensions инициализированы в PostgreSQL')
}
catch (error) {
@@ -1,179 +0,0 @@
/**
* Полноценное создание «обычного» пайщика (role='user', не член совета) через бэкенд.
* Делает: keypair → registrator::adduser → registrator::changekey → mongo individual →
* postgres users (is_registered=true, status=active).
*
* Идемпотентно:
* - если username уже в registrator.accounts ончейн с нужным public_key — ничего не делает,
* просто читает public_key и печатает результат (wif=null, т.к. приватный ключ потерян
* между запусками — сохранять его должен harness в своём state).
* - если username уже есть в mongo/pg, но ончейн отсутствует — fail loud: «dirty state».
*
* Вывод — JSON одной строкой в stdout: `{username, email, wif, publicKey}` (wif=null если
* участник был найден без перезаписи ключа).
*
* Логи — в stderr, чтобы stdout остался чисто машиночитаемым.
*/
import ecc from 'eosjs-ecc'
import { Client } from 'pg'
import mongoose from 'mongoose'
import { Generator } from '@coopenomics/factory'
import { RegistratorContract, type Cooperative } from 'cooptypes'
import Blockchain from '../blockchain'
import config from '../configs'
import { generateRandomSHA256 } from '../utils/randomHash'
const log = (...a: unknown[]) => console.error('[add-plain-participant]', ...a)
interface Args {
username: string
email: string
firstName: string
lastName: string
middleName: string
coopname?: string
}
async function getOnChainActiveKey(blockchain: Blockchain, username: string): Promise<string | null> {
// registrator.accounts (scope=registrator) хранит карточку пайщика, но не публичный ключ —
// ключ живёт в eosio-аккаунте через permission `active`. Берём оттуда.
try {
const acc = await blockchain.api.rpc.get_account(username)
const active = acc.permissions?.find((p: any) => p.perm_name === 'active')
return active?.required_auth?.keys?.[0]?.key ?? null
} catch {
return null
}
}
export async function addPlainParticipant(args: Args) {
const coopname = args.coopname ?? config.provider
const { username, email, firstName, lastName, middleName } = args
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
// === 1. ончейн-проверка ===
const existingKey = await getOnChainActiveKey(blockchain, username)
let wif: string | null = null
let publicKey: string
if (existingKey) {
log(`on-chain: ${username} уже есть с public_key=${existingKey} — ключ не генерируется, создания участника пропускаю`)
publicKey = existingKey
} else {
// === 2. генерируем новый keypair ===
wif = await ecc.randomKey()
publicKey = ecc.privateToPublic(wif)
log(`новый keypair для ${username}: pub=${publicKey}`)
// === 3. registrator::adduser ===
const addData: RegistratorContract.Actions.AddUser.IAddUser = {
coopname,
referer: '',
username,
type: 'individual',
created_at: new Date().toISOString().slice(0, 19),
initial: '100.0000 RUB',
minimum: '100.0000 RUB',
spread_initial: false,
meta: `Тестовый пайщик ${firstName} ${lastName}`,
registration_hash: generateRandomSHA256(),
}
await blockchain.api.transact({
actions: [{
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.AddUser.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data: addData,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log('registrator::adduser OK')
// === 4. registrator::changekey — выдаём сгенерированный ключ ===
const keyData: RegistratorContract.Actions.ChangeKey.IChangeKey = {
coopname,
username,
public_key: publicKey,
changer: coopname,
}
await blockchain.api.transact({
actions: [{
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.ChangeKey.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data: keyData,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log('registrator::changekey OK')
}
// === 5. Mongo: individual запись (upsert) ===
const mongoUri = process.env.MONGO_URI
if (!mongoUri) throw new Error('MONGO_URI не задан в env')
const generator = new Generator()
await generator.connect(mongoUri)
const individual: Cooperative.Users.IIndividualData = {
username,
first_name: firstName,
last_name: lastName,
middle_name: middleName,
birthdate: '1990/01/01',
phone: '+70000000000',
email,
full_address: 'Тестовый адрес',
passport: {
series: 1111,
number: 100000 + Math.floor(Math.random() * 899999),
issued_by: 'УФМС России (тест)',
issued_at: '2010/01/01',
code: '000-000',
},
}
await generator.save('individual', individual)
log('mongo individual upserted')
await mongoose.disconnect().catch(() => {})
// === 6. Postgres: users запись ===
const pg = new Client({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
user: process.env.POSTGRES_USERNAME,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DATABASE,
})
await pg.connect()
await pg.query(`
INSERT INTO "users" (username, email, type, role, status, is_registered,
has_account, is_email_verified, public_key,
created_at, updated_at)
VALUES ($1, $2, 'individual', 'user', 'active', true,
true, true, $3,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
public_key = EXCLUDED.public_key,
status = 'active',
is_registered = true,
updated_at = CURRENT_TIMESTAMP
`, [username, email, publicKey])
log('postgres users upserted')
await pg.end()
// === 7. JSON в stdout ===
const result = { username, email, wif, publicKey, coopname }
process.stdout.write(JSON.stringify(result))
}
// CLI: add-plain-participant <username> <email> <firstName> <lastName> <middleName>
const [, , username, email, firstName, lastName, middleName = ''] = process.argv
if (!username || !email || !firstName || !lastName) {
console.error('Usage: add-plain-participant <username> <email> <firstName> <lastName> [middleName]')
process.exit(2)
}
addPlainParticipant({ username, email, firstName, lastName, middleName })
.then(() => process.exit(0))
.catch((e) => { console.error(e); process.exit(1) })
@@ -1,46 +0,0 @@
/**
* Одноразовый скрипт: добавляет тестового пайщика через registrator::adduser,
* чтобы parser уловил live ledger2-actions и controller получил их в
* blockchain_actions (для проверки ProcessRegistryService end-to-end).
*
* Epic 1 addendum удалил wjournal/journal из ledger2 (были в RAM). Phase A
* ProcessRegistry теперь читает blockchain_actions — history восстанавливается
* из action traces (apply + walletop + debit + credit).
*
* Запуск: pnpm run cli add-test-user <username>
*/
import Blockchain from '../blockchain'
import { generateRandomSHA256 } from '../utils/randomHash'
import config from '../configs'
import { RegistratorContract } from 'cooptypes'
export async function addTestUser(username: string) {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const registration_hash = generateRandomSHA256()
console.log(`\nДобавляем пайщика ${username} с registration_hash=${registration_hash}`)
const data: RegistratorContract.Actions.AddUser.IAddUser = {
coopname: 'voskhod',
referer: '',
username,
type: 'individual',
created_at: '2025-01-15T10:00:00',
initial: '100.0000 RUB',
minimum: '200.0000 RUB',
spread_initial: true,
meta: 'Тестовый пайщик для проверки ProcessRegistry',
registration_hash,
}
await blockchain.addUser(data)
console.log(`\nУчастник ${username} добавлен. process_hash=${registration_hash}`)
console.log(`Проверить on-chain:`)
console.log(` cleos get actions ledger2 -1 -5 # последние apply/walletop/debit/credit`)
console.log(` cleos get table ledger2 voskhod accounts2 # актуальные балансы счетов`)
console.log(` cleos get table ledger2 voskhod wallets2 # общекооперативные кошельки`)
console.log(` cleos get table registrator voskhod candidates2 # карточка кандидата`)
console.log(`Проверить GraphQL: query { process(hash: "${registration_hash}", coopname: "voskhod") { process_type actions { action } delta_history { table primary_key } } }`)
}
@@ -1,66 +0,0 @@
/**
* Регенерация ключей для пайщиков ivanpetrov, ekaterina.
* Генерирует свежий keypair, делает registrator::changekey от имени кооператива,
* пишет новый wif/publicKey в фикстуры docs-harness/state/participants/<name>.json.
*
* Используется после reboot, когда on-chain аккаунты остались, а WIF потерян
* (фикстуры в .gitignore — не сохраняются между сессиями).
*/
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import ecc from 'eosjs-ecc'
import { RegistratorContract } from 'cooptypes'
import Blockchain from '../blockchain'
import config from '../configs'
const log = (...a: unknown[]) => console.error('[rotate-keys]', ...a)
const COOPNAME = 'voskhod'
const here = path.dirname(fileURLToPath(import.meta.url))
const FIXTURE_DIR = path.resolve(here, '../../../docs-harness/state/participants')
const PARTICIPANTS = ['ivanpetrov', 'ekaterina']
async function rotate(blockchain: Blockchain, username: string) {
const wif = await ecc.randomKey()
const publicKey = ecc.privateToPublic(wif)
log(`${username}: новый ключ pub=${publicKey}`)
await blockchain.api.transact({
actions: [{
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.ChangeKey.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
public_key: publicKey,
changer: COOPNAME,
} as RegistratorContract.Actions.ChangeKey.IChangeKey,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log(`${username}: registrator::changekey OK`)
const file = path.join(FIXTURE_DIR, `${username}.json`)
const prev = JSON.parse(fs.readFileSync(file, 'utf8'))
const next = {
...prev,
wif,
publicKey,
}
fs.writeFileSync(file, JSON.stringify(next))
log(`${username}: фикстура обновлена ${file}`)
}
async function main() {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
for (const u of PARTICIPANTS) {
await rotate(blockchain, u)
}
log('готово')
}
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1) })
@@ -1,51 +0,0 @@
# seed-capital
Идемпотентные seed-фазы для контракта CAPITAL. Используются документационным
harness'ом (`components/docs-harness/bin/shoot.mjs`, ключ `meta.prepare`) для
автоматического дохода до конкретной точки UI без ручного клика по браузеру.
## Запуск
```bash
# одну фазу
pnpm --filter @coopenomics/boot exec esno src/scripts/seed-capital/index.ts 01-programs
# все по порядку
pnpm --filter @coopenomics/boot exec esno src/scripts/seed-capital/index.ts all
```
## Фазы
| ID | Что делает | Зависит от |
| --------------------- | --------------------------------------------------------------------------- | ---------- |
| `01-programs` | Программы УХД (id=3) + Благорост (id=4) + `capital::setconfig` | `boot:extra` (председатель + совет) |
| `02-extension-config` | Запись `extensions.capital` в postgres со всеми `*_done=true` (dev-shortcut). Без неё controller отдаёт 500 «Конфигурация расширения capital не найдена», и UI редиректит на страницу адаптации | работающий postgres |
| `03-projects` | 12 проектов и 30 компонентов из `_blago/INDEX.md` | `01-programs` |
| `04-contributor` | Регистрация председателя `ant` как Contributor (программно через `Capital.GenerateCapitalRegistrationDocuments` + `CompleteCapitalRegistration`). Без неё UI после адаптации показывает заглушку «Ранним участникам» и не пускает в Мастерскую | `02-extension-config`, controller :2998 |
`phases/02b-real-onboarding.ts` — зарезервированная фаза для будущей задачи: реально провести 5 решений совета через SDK (vote ×3 → authorize → exec). Не подключена к диспетчеру; нужна только когда будем документировать сам процесс адаптации в UI.
Будущие фазы (`02-participants`, `04-agreements`, `05-contributions`,
`06-investments`, `07-result`) добавляются по мере появления сценариев в harness.
## Идемпотентность
Каждая фаза перед действием читает цепочку (`getCoopProgramWallet`,
`getProject`, `state`-таблица CAPITAL). Если запись уже есть — пропускает.
Так что повторный прогон без `reboot:extra` — no-op.
## Соглашения
- **Хеши проектов детерминированные** — `sha256("blago:project:<id>")`. Это
гарантирует одинаковые скриншоты между прогонами без `--reboot`.
- **Названия — человеческие**, из `components/blago-cli/_blago/INDEX.md`.
Никаких `Project N` / `Test Project`.
- **Источник параметров программ и `setconfig`** — `src/tests/capital.test.ts`
(строки 140–288). Если меняется тест — синхронизируй сюда.
## Откуда берутся проекты
Список захардкожен в `data/projects.ts` ровно по индексу
`components/blago-cli/_blago/INDEX.md`. Когда там появляются новые проекты —
добавь вручную (автоматического импорта нет специально, чтобы рантайм скрипта
не зависел от парсинга чужого markdown).
@@ -1,71 +0,0 @@
import { createHash } from 'node:crypto'
export interface ProjectSeed {
id: number
title: string
parentId: number | null
}
export const ROOT_HASH = '0000000000000000000000000000000000000000000000000000000000000000'
export function projectHash(id: number): string {
return createHash('sha256').update(`blago:project:${id}`).digest('hex')
}
// Источник: components/blago-cli/_blago/INDEX.md
// 12 проектов + 30 компонентов. id и связи — ровно как в индексе.
// «Проект» и «Компонент» — словарные термины (см. _blago/.../c5-slovar-...md):
// проект = направление работы кооператива, компонент = атомарная задача внутри проекта.
//
// «Кошелёк пайщика» (id=48) — реальный проект кооператива, в индексе _blago/INDEX.md
// его ещё нет, но он существует как направление: контракт лицевых счетов, членские
// взносы, интеграция «кнопка Благорост» в сторонние приложения. Добавлен здесь.
export const PROJECTS: ProjectSeed[] = [
// === Проекты ===
{ id: 1, parentId: null, title: 'Приложение «Стол Заказов»' },
{ id: 4, parentId: null, title: 'Пропаганда кооперации' },
{ id: 7, parentId: null, title: 'Приложение «Благорост»' },
{ id: 11, parentId: null, title: 'Кооперативный мессенджер «Оп!Кооп»' },
{ id: 13, parentId: null, title: 'Платформа Цифрового Кооператива' },
{ id: 16, parentId: null, title: 'Платформа Каталога Приложений' },
{ id: 17, parentId: null, title: 'Платформа Кооперативной Экономики' },
{ id: 29, parentId: null, title: 'Гранты и финансирование' },
{ id: 33, parentId: null, title: 'Платформа отчётов для ФНС/ФСС' },
{ id: 34, parentId: null, title: 'Платформа Управления' },
{ id: 36, parentId: null, title: 'Платформа образования' },
{ id: 43, parentId: null, title: 'Облачный провайдер' },
{ id: 48, parentId: null, title: 'Кошелёк пайщика' },
// === Компоненты ===
{ id: 3, parentId: 1, title: 'Минимальный продукт' },
{ id: 8, parentId: 7, title: 'Командный инструмент blago-cli v1' },
{ id: 10, parentId: 7, title: 'Версия 1.1' },
{ id: 12, parentId: 4, title: 'Системный логотип v1' },
{ id: 14, parentId: 13, title: 'Версия 3' },
{ id: 18, parentId: 17, title: 'Консенсус Proof of Authority' },
{ id: 19, parentId: 17, title: 'Версия 1.1' },
{ id: 20, parentId: 4, title: 'Видео по кооперации v1' },
{ id: 21, parentId: 4, title: 'Посты и статьи v1' },
{ id: 22, parentId: 4, title: 'Гость в студии v1' },
{ id: 23, parentId: 4, title: 'Партнёры и Соглашения v1' },
{ id: 24, parentId: 4, title: 'Российский Университет Кооперации v1' },
{ id: 25, parentId: 4, title: 'Международный форум кооперации v1' },
{ id: 26, parentId: 4, title: 'Настольная игра v1' },
{ id: 27, parentId: 4, title: 'Лендинги и посадочные страницы v1' },
{ id: 28, parentId: 4, title: 'Кооператив блогеров v1' },
{ id: 30, parentId: 4, title: 'Концепция (апрель 2026 — апрель 2027)' },
{ id: 31, parentId: 13, title: 'Общие собрания v2' },
{ id: 32, parentId: 17, title: 'Верификатор документов v1' },
{ id: 35, parentId: 13, title: 'Авто-секретарь чатов и звонков' },
{ id: 37, parentId: 36, title: 'MVP' },
{ id: 38, parentId: 36, title: 'Концепция' },
{ id: 39, parentId: 7, title: 'Версия 1.2' },
{ id: 40, parentId: 33, title: 'MVP' },
{ id: 41, parentId: 17, title: 'Универсальный индексер блокчейна (parser v2)' },
{ id: 42, parentId: 13, title: 'Удостоверение пайщика (coopID)' },
{ id: 44, parentId: 43, title: 'MVP' },
{ id: 45, parentId: 13, title: 'Хранилище файлов v1' },
{ id: 46, parentId: 17, title: 'Реестр кооперативных стандартов v1' },
{ id: 47, parentId: 16, title: 'MVP' },
{ id: 49, parentId: 48, title: 'MVP v1' },
]
@@ -1,110 +0,0 @@
/**
* CLI-диспетчер seed-фаз CAPITAL.
*
* pnpm --filter @coopenomics/boot exec esno src/scripts/seed-capital/index.ts <phase>
* ... <phase> <phase> ...
* ... --up-to=<phase>
* ... all
*
* Каждая фаза идемпотентна. Список фаз прогоняется в указанном порядке.
* `--up-to=X` прогоняет все фазы в каноническом порядке до X включительно —
* это удобно когда нужно «довести систему до состояния перед X и оставить как есть»
* (например, для ручного теста UI на конкретном этапе).
*
* Логи — в stderr (чтобы stdout остался чистым для будущих скриптов).
*/
import { phase01 } from './phases/01-programs'
import { phase02 } from './phases/02-extension-config'
import { phase02Reset } from './phases/02-reset-onboarding'
import { phase02b } from './phases/02b-real-onboarding'
import { phase03 } from './phases/03-projects'
import { phase04 } from './phases/04-contributor'
import { phase04b } from './phases/04b-approve-contributor'
import { phase05 } from './phases/05-additional-contributors'
import { phase06 } from './phases/06-create-project-koshelek'
import { phase07 } from './phases/07-master-and-plan'
import { phase07b } from './phases/07b-clearance-all'
import { phase08 } from './phases/08-investments'
import { phase09 } from './phases/09-tasks'
import { phase09b } from './phases/09b-artifacts'
import { phase10 } from './phases/10-commits-and-voting'
import { phase10a } from './phases/10a-pending-commit'
import { phase11 } from './phases/11-cast-votes'
import { phase12 } from './phases/12-recalc-and-calc-votes'
import { phase13 } from './phases/13-push-result'
import { phase14 } from './phases/14-convert-segments'
import { phase15 } from './phases/15-active-component'
const PHASES: Record<string, () => Promise<void>> = {
'01-programs': phase01,
'02-extension-config': phase02,
'02-reset-onboarding': phase02Reset,
'02b-real-onboarding': phase02b,
'03-projects': phase03,
'04-contributor': phase04,
'04b-approve-contributor': phase04b,
'05-additional-contributors': phase05,
'06-create-project-koshelek': phase06,
'07-master-and-plan': phase07,
'07b-clearance-all': phase07b,
'08-investments': phase08,
'09-tasks': phase09,
'09b-artifacts': phase09b,
'10a-pending-commit': phase10a,
'10-commits-and-voting': phase10,
'11-cast-votes': phase11,
'12-recalc-and-calc-votes': phase12,
'13-push-result': phase13,
'14-convert-segments': phase14,
'15-active-component': phase15,
}
const PHASE_ORDER = Object.keys(PHASES)
function usage(): never {
console.error('Usage: seed-capital <phase> [<phase> ...] | --up-to=<phase> | all')
console.error(`Available phases: ${PHASE_ORDER.join(', ')}`)
process.exit(2)
}
async function runPhases(names: string[]) {
for (const name of names) {
const fn = PHASES[name]
if (!fn) {
console.error(`Unknown phase: ${name}`)
console.error(`Available: ${PHASE_ORDER.join(', ')}`)
process.exit(2)
}
console.error(`\n=== seed-capital: ${name} ===`)
await fn()
}
}
async function main() {
const args = process.argv.slice(2)
if (args.length === 0) usage()
if (args.length === 1 && args[0] === 'all') {
await runPhases(PHASE_ORDER)
return
}
const upTo = args.find((a) => a.startsWith('--up-to='))
if (upTo) {
const target = upTo.slice('--up-to='.length)
const idx = PHASE_ORDER.indexOf(target)
if (idx < 0) {
console.error(`Unknown phase in --up-to: ${target}`)
console.error(`Available: ${PHASE_ORDER.join(', ')}`)
process.exit(2)
}
await runPhases(PHASE_ORDER.slice(0, idx + 1))
return
}
await runPhases(args)
}
main()
.then(() => process.exit(0))
.catch((e) => { console.error(e); process.exit(1) })
@@ -1,146 +0,0 @@
/**
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
* (id=4, type='capital') + capital::setconfig.
*
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
* setconfig — только если `state` пуст.
*
* Параметры скопированы один-в-один из src/tests/capital.test.ts:140-288 —
* чтобы скриншоты документации соответствовали поведению, под которое
* настроены контрактные тесты.
*/
import { CapitalContract, SovietContract } from 'cooptypes'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:01]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
// Дублируем числа из tests/capital/consts.ts — импорт оттуда тащит цепочку
// модулей с `import { expect } from 'vitest'`, а seed-скрипт запускается
// через esno, не под vitest-раннером.
const SOURCE_PROGRAM_ID = 3
const CAPITAL_PROGRAM_ID = 4
async function getCoopProgram(blockchain: Blockchain, coopname: string, programId: number) {
const rows = await blockchain.getTableRows(
SovietContract.contractName.production,
coopname,
'programs',
1000,
programId.toString(),
programId.toString(),
)
return rows[0]
}
export async function phase01(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
// --- Программа УХД (Договор УХД) ---
const sourceProgram = await getCoopProgram(blockchain, COOPNAME, SOURCE_PROGRAM_ID)
if (!sourceProgram) {
log(`создаю программу УХД (id=${SOURCE_PROGRAM_ID})`)
const data: SovietContract.Actions.Programs.CreateProgram.ICreateProgram = {
coopname: COOPNAME,
is_can_coop_spend_share_contributions: true,
username: CHAIRMAN,
title: 'Договор УХД',
announce: '',
description: '',
preview: '',
images: '',
calculation_type: 'free',
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'generator',
}
await blockchain.api.transact({
actions: [{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Programs.CreateProgram.actionName,
authorization: [{ actor: CHAIRMAN, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
} else {
log(`программа УХД (id=${SOURCE_PROGRAM_ID}) уже есть — пропуск`)
}
// --- Программа Благорост ---
const capitalProgram = await getCoopProgram(blockchain, COOPNAME, CAPITAL_PROGRAM_ID)
if (!capitalProgram) {
log(`создаю программу Благорост (id=${CAPITAL_PROGRAM_ID})`)
const data: SovietContract.Actions.Programs.CreateProgram.ICreateProgram = {
coopname: COOPNAME,
is_can_coop_spend_share_contributions: false,
username: CHAIRMAN,
title: 'Благорост',
announce: '',
description: '',
preview: '',
images: '',
calculation_type: 'free',
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
}
await blockchain.api.transact({
actions: [{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Programs.CreateProgram.actionName,
authorization: [{ actor: CHAIRMAN, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
} else {
log(`программа Благорост (id=${CAPITAL_PROGRAM_ID}) уже есть — пропуск`)
}
// --- capital::setconfig ---
// Проверяем `state`-таблицу: если запись для voskhod уже есть, повторно не пушим
// (даже несмотря на то, что setconfig сам по себе перезаписывает конфиг).
const stateRows = await blockchain.getTableRows(
CapitalContract.contractName.production,
CapitalContract.contractName.production,
'state',
1,
COOPNAME,
COOPNAME,
)
if (!stateRows || stateRows.length === 0) {
log('инициализирую CAPITAL config')
const data: CapitalContract.Actions.SetConfig.ISetConfig = {
coopname: COOPNAME,
config: {
coordinator_bonus_percent: 4,
expense_pool_percent: 100,
coordinator_invite_validity_days: 30,
voting_period_in_days: 7,
authors_voting_percent: 38.2,
creators_voting_percent: 38.2,
energy_decay_rate_per_day: 0.11,
level_depth_base: 1000,
level_growth_coefficient: 1.5,
energy_gain_coefficient: 0.01,
},
}
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.SetConfig.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
} else {
log('CAPITAL config уже инициализирован — пропуск')
}
log('фаза 01 завершена')
}
@@ -1,30 +0,0 @@
/**
* Фаза 02 — наличие записи `extensions.capital` в PostgreSQL controller'а.
*
* Controller хранит state онбординга в таблице `extensions` (запись с `name='capital'`).
* UI запрашивает его через `Queries.Capital.GetOnboardingState`. Если записи нет —
* controller возвращает 500 и UI default'ом показывает страницу адаптации, любой URL
* под `/capital/*` редиректит туда.
*
* В dev-конфигурации существует shortcut: запись создаётся со всеми `*_done=true` и
* предзаполненными `*_hash` — UI считает онбординг пройденным и сразу пускает в
* Мастерскую. Этот shortcut живёт в `boot/src/postgres-init.ts:initExtensionsInPostgres`,
* вызывается из `bootExtra()`. Если по какой-то причине запись отсутствует (другая БД,
* был сброс postgres volumes между прогонами и т.п.) — здесь восстанавливаем.
*
* Идемпотентно: `initExtensionsInPostgres` использует `INSERT ... ON CONFLICT DO UPDATE`,
* повторный прогон обновляет updated_at и не дублирует.
*
* РЕАЛЬНОЕ проведение онбординга через совет (фабричный документ → propose → vote × 3 →
* authorize → exec) зарезервировано в `02b-real-onboarding.ts` и нужно отдельно, когда
* мы будем документировать сам процесс адаптации в UI.
*/
import { initExtensionsInPostgres } from '../../../postgres-init'
const log = (...a: unknown[]) => console.error('[seed-capital:02]', ...a)
export async function phase02(): Promise<void> {
log('инициализирую extensions.capital в postgres (dev-shortcut: все *_done=true)')
await initExtensionsInPostgres()
log('фаза 02 завершена')
}
@@ -1,60 +0,0 @@
/**
* Фаза 02-reset-onboarding — обнуляет флаги адаптации Capital extension.
*
* `bootExtra()` через `initExtensionsInPostgres` ставит все
* `onboarding_*_done=true` как dev-shortcut, и UI считает онбординг
* председателя завершённым (CapitalOnboardingCard прячется, председателя
* сразу пускают в Мастерскую). Для скриншотов адаптации нам нужно
* противоположное состояние: запись существует, но все 5 шагов в pending.
*
* Эта фаза делает UPDATE на существующей записи `extensions.capital`,
* выставляя все 5 `onboarding_*_done` в false и удаляя все 5
* `onboarding_*_hash` ключей. Логика UI на этих полях:
* *_done=true → completed (зелёная галка)
* *_done=false, *_hash≠null → in_progress («Ожидаем решение совета»)
* *_done=false, *_hash=null → pending (кнопка «Объявить собрание совета»)
* Нам нужен последний — поэтому обнуляем оба.
*
* Идемпотентно.
*/
import { Client } from 'pg'
const log = (...a: unknown[]) => console.error('[seed-capital:02-reset]', ...a)
export async function phase02Reset(): Promise<void> {
const pg = new Client({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
user: process.env.POSTGRES_USERNAME,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DATABASE,
})
await pg.connect()
try {
log('сбрасываю onboarding_*_done=false и удаляю onboarding_*_hash в extensions.capital.config')
const result = await pg.query(`
UPDATE "extensions"
SET config = (config
|| jsonb_build_object(
'onboarding_generator_program_template_done', false,
'onboarding_generation_contract_template_done', false,
'onboarding_generator_offer_template_done', false,
'onboarding_blagorost_provision_done', false,
'onboarding_blagorost_offer_template_done', false
))
- 'onboarding_generator_program_template_hash'
- 'onboarding_generation_contract_template_hash'
- 'onboarding_generator_offer_template_hash'
- 'onboarding_blagorost_provision_hash'
- 'onboarding_blagorost_offer_template_hash',
updated_at = CURRENT_TIMESTAMP
WHERE name = 'capital'
`)
log(`обновлено строк: ${result.rowCount}`)
if (result.rowCount === 0) {
log('запись extensions.capital отсутствует — сначала запусти 02-extension-config')
}
} finally {
await pg.end()
}
}
@@ -1,330 +0,0 @@
/**
* Фаза 02b — РЕАЛЬНОЕ проведение адаптации через совет (зарезервировано на будущее).
*
* НЕ ПОДКЛЮЧЕНА к диспетчеру seed-capital по умолчанию. Используется только если нужно
* задокументировать сам процесс адаптации в UI (председатель кликает «Объявить собрание
* совета», совет голосует, протокол фиксируется). Для большинства harness-сценариев,
* которым достаточно «просто добраться до Мастерской», работает phase 02-extension-config —
* она использует тот же dev-shortcut, что и `bootExtra()`: проставляет в postgres-конфиге
* extensions.capital все *_done=true, и UI скипает онбординг.
*
* Эта фаза эмулирует UI-флоу через SDK:
* 1) Председатель логинится в controller через SDK.
* 2) Для каждого шага:
* a) Mutations.Documents.GenerateDocument — фабрика сгенерит положение/оферту;
* b) Mutations.Capital.CompleteOnboardingStep — controller сам пушит decision
* на цепочку и сохраняет его hash в onboarding state;
* c) находим decision по hash в таблице soviet.decisions;
* d) три члена совета (`petr`, `anna`, `mikhail`) подписывают и пушат
* soviet::votefor (порог консенсуса 50% от 5 = нужно ≥3 голосов);
* e) председатель `ant` пушит soviet::authorize + soviet::exec.
*
* ЗАМЕЧАНИЕ. На момент создания этой фазы был обнаружен блокер: controller хранит
* onboarding state в postgres-таблице `extensions` (запись с `name='capital'`).
* Если этой записи нет — `Mutations.Capital.CompleteOnboardingStep` падает с
* «Конфигурация расширения capital не найдена». В dev-конфигурации запись создаётся
* через `initExtensionsInPostgres()` СРАЗУ с `*_done=true`, поэтому реальный путь
* через совет в этой среде не запускается. Чтобы оживить эту фазу, нужно сначала
* вставить пустой config (без done-флагов), потом гонять её.
*/
import { Client, Mutations, Queries } from '@coopenomics/sdk'
import { Cooperative, SovietContract } from 'cooptypes'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:02b]', ...a)
// FreeDecision — общий протокол решения совета (registry_id=600). Используется
// для authorize/exec любого решения как «обёртка» — председатель подписывает
// HTML-протокол, его hash попадает в decision как `final_document`.
const FREE_DECISION_REGISTRY_ID = Cooperative.Registry.FreeDecision.registry_id
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const CHAIRMAN_EMAIL = 'ivanov@example.com'
// 3 голоса достаточно (5 членов × 50% = >2.5). Председатель не голосует:
// в реальной системе он только authorize/exec.
const VOTING_MEMBERS = ['petr', 'anna', 'mikhail']
// Источник: components/desktop/extensions/capital/features/Onboarding/model/composable.ts
// Если меняется там — синхронизировать здесь.
interface IStep {
id: 'generator_program_template' | 'generation_contract_template' | 'blagorost_program' | 'generator_offer_template' | 'blagorost_offer_template'
registry_id: number
doneFlag: string
hashFlag: string
title: string
question: string
}
const STEPS: IStep[] = [
{
id: 'generator_program_template',
registry_id: 994,
doneFlag: 'generator_program_template_done',
hashFlag: 'onboarding_generator_program_template_hash',
title: 'Положение о целевой потребительской программе "ГЕНЕРАТОР"',
question: 'О утверждении Положения о целевой потребительской программе «ГЕНЕРАТОР»',
},
{
id: 'generation_contract_template',
registry_id: 997,
doneFlag: 'generation_contract_template_done',
hashFlag: 'onboarding_generation_contract_template_hash',
title: 'Шаблон договора участия в хозяйственной деятельности',
question: 'О утверждении шаблона договора участия в хозяйственной деятельности',
},
{
id: 'blagorost_program',
registry_id: 998,
doneFlag: 'blagorost_provision_done',
hashFlag: 'onboarding_blagorost_provision_hash',
title: 'Положение о ЦПП «БЛАГОРОСТ»',
question: 'О утверждении Положения о целевой потребительской программе «БЛАГОРОСТ»',
},
{
id: 'generator_offer_template',
registry_id: 995,
doneFlag: 'generator_offer_template_done',
hashFlag: 'onboarding_generator_offer_template_hash',
title: 'Шаблон пользовательского соглашения (оферты) по участию в целевой потребительской программе "ГЕНЕРАТОР"',
question: 'О утверждении шаблона пользовательского соглашения (оферты) по участию в целевой потребительской программе "ГЕНЕРАТОР"',
},
{
id: 'blagorost_offer_template',
registry_id: 999,
doneFlag: 'blagorost_offer_template_done',
hashFlag: 'onboarding_blagorost_offer_template_hash',
title: 'Пользовательское соглашение (оферта) по ЦПП «БЛАГОРОСТ»',
question: 'О утверждении пользовательского соглашения (оферты) по присоединению к ЦПП «БЛАГОРОСТ»',
},
]
async function getDecisionByHash(
blockchain: Blockchain,
decisionHash: string,
): Promise<{ id: number, approved: boolean, project_id: string } | null> {
// Решений в проекте не больше нескольких десятков; читаем последние 1000 и ищем по hash.
const rows = await blockchain.getTableRows(
SovietContract.contractName.production,
COOPNAME,
'decisions',
1000,
)
// Цепочка возвращает hash в lowercase, controller — UPPERCASE; сравниваем case-insensitive.
const target = decisionHash.toLowerCase()
const found = rows.find((r: { hash: string }) => (r.hash ?? '').toLowerCase() === target) as
| { id: number; approved: boolean; meta?: string }
| undefined
if (!found) return null
// FreeDecision-протокол требует project_id, который controller кладёт в decision.meta.
let projectId = ''
try {
const meta = JSON.parse(found.meta ?? '{}') as { project_id?: string }
projectId = meta.project_id ?? ''
} catch {}
return { id: Number(found.id), approved: Boolean(found.approved), project_id: projectId }
}
export async function phase02b(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
// Берём chain_id из самой цепочки — не хардкодим.
const info = await blockchain.getInfo()
const chainId = info.chain_id
const chainUrl = `${config.network.protocol}://${config.network.host}${config.network.port}`
const apiUrl = process.env.CONTROLLER_GRAPHQL_URL || 'http://127.0.0.1:2998/v1/graphql'
const wif = config.private_keys[0]
if (!wif) throw new Error('EOSIO_PRV_KEY не задан в env — без него seed-capital:02 не залогинит председателя в controller')
// SDK-клиент под председателем — для GenerateDocument + CompleteOnboardingStep.
const client = Client.create({
api_url: apiUrl,
chain_url: chainUrl,
chain_id: chainId,
wif,
username: CHAIRMAN,
})
log(`логин председателя ${CHAIRMAN_EMAIL} в controller (${apiUrl})...`)
await client.login(CHAIRMAN_EMAIL, wif)
// Все 5 членов совета используют один общий ключ (см. boot/src/init/infra.ts:
// changekey раздаёт каждому config.default_public_key). Vote SDK-класса подписывает
// голос за любого username — он уже инициализирован в Client.
for (const step of STEPS) {
// 1. Прочитать состояние онбординга (для идемпотентности).
const stateBefore = await client.Query(Queries.Capital.GetOnboardingState.query) as Record<string, unknown>
const onboardingBefore = stateBefore[Queries.Capital.GetOnboardingState.name] as Record<string, unknown>
if (onboardingBefore?.[step.doneFlag]) {
log(`[${step.id}] уже принято — пропуск`)
continue
}
// 2. Сгенерировать фабричный документ (положение или оферта).
log(`[${step.id}] фабрика documentRegistry=${step.registry_id}`)
const docResp = await client.Mutation(
Mutations.Documents.GenerateDocument.mutation,
{
variables: {
input: {
data: {
coopname: COOPNAME,
username: CHAIRMAN,
registry_id: step.registry_id,
},
},
} as Mutations.Documents.GenerateDocument.IInput,
},
) as Record<string, { hash: string, html: string, full_title: string }>
const generatedDoc = docResp[Mutations.Documents.GenerateDocument.name]
// 3. Завершить шаг адаптации — controller сам propose-ит решение совета на цепочке.
log(`[${step.id}] CompleteOnboardingStep`)
const completeResp = await client.Mutation(
Mutations.Capital.CompleteOnboardingStep.mutation,
{
variables: {
data: {
step: step.id,
title: step.title,
question: step.question,
decision: generatedDoc.html,
},
} as Mutations.Capital.CompleteOnboardingStep.IInput,
},
) as Record<string, Record<string, unknown>>
const newState = completeResp[Mutations.Capital.CompleteOnboardingStep.name]
const decisionHash = newState?.[step.hashFlag] as string | undefined
if (!decisionHash) {
throw new Error(`[${step.id}] CompleteOnboardingStep вернул state без ${step.hashFlag} — controller не создал решение?`)
}
// 4. Найти decision_id по hash в таблице soviet.decisions.
let decision = await getDecisionByHash(blockchain, decisionHash)
// На холодной системе индексация ноды иногда отстаёт на 1-2 блока — короткий ретрай.
for (let i = 0; !decision && i < 5; i++) {
await new Promise(r => setTimeout(r, 1000))
decision = await getDecisionByHash(blockchain, decisionHash)
}
if (!decision) throw new Error(`[${step.id}] не нашёл decision с hash=${decisionHash} в таблице soviet.decisions`)
log(`[${step.id}] decision_id=${decision.id}, hash=${decisionHash.slice(0, 12)}`)
// 5. Голосование: 3 члена совета. client.Vote.voteFor подписывает голос
// общим WIF (Client.login его проставил в Vote-классе).
for (const username of VOTING_MEMBERS) {
const voteData = await client.Vote.voteFor(COOPNAME, username, decision.id)
log(`[${step.id}] votefor ${username}`)
await blockchain.api.transact(
{
actions: [
{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Decisions.VoteFor.actionName,
authorization: [{ actor: username, permission: 'active' }],
data: voteData,
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
}
// 6. Authorize: председатель генерирует и подписывает РЕАЛЬНЫЙ протокол
// решения совета (FreeDecision, registry_id=600). Для генерации
// нужны decision_id + project_id (из meta решения). Никаких fakeDocument —
// иначе controller'ные verify-утилиты падают на «документ с хешем X не
// найден» при последующих flow.
//
// Factory.getDecision читает votefor-actions через simple-explorer-api
// (parser-индекс). После votefor parser индексирует ~1-2 блока
// (≈700ms-2s). Без паузы получаем «Голоса за решение не найдены» —
// ждём с retry до 10s.
let protocolDoc: { hash: string; doc_hash: string; meta_hash: string; meta: unknown; html: string; binary?: string; full_title?: string } | null = null
let lastErr: unknown = null
for (let attempt = 1; attempt <= 6; attempt++) {
await new Promise(r => setTimeout(r, attempt === 1 ? 1500 : 2000))
log(`[${step.id}] генерирую протокол (FreeDecision, decision_id=${decision.id}) attempt=${attempt}`)
try {
const protocolGen = await client.Mutation(
Mutations.Documents.GenerateDocument.mutation,
{
variables: {
input: {
data: {
coopname: COOPNAME,
username: CHAIRMAN,
registry_id: FREE_DECISION_REGISTRY_ID,
decision_id: decision.id,
project_id: decision.project_id,
lang: 'ru',
},
},
} as Mutations.Documents.GenerateDocument.IInput,
},
) as Record<string, { hash: string; doc_hash: string; meta_hash: string; meta: unknown; html: string; binary?: string; full_title?: string }>
protocolDoc = protocolGen[Mutations.Documents.GenerateDocument.name]
break
} catch (e) {
lastErr = e
log(`[${step.id}] generate FreeDecision failed (parser ещё индексирует votes?): retry ${attempt}/6`)
}
}
if (!protocolDoc) throw lastErr ?? new Error(`[${step.id}] не удалось сгенерировать FreeDecision протокол`)
log(`[${step.id}] подписываю протокол hash=${protocolDoc.hash.slice(0, 12)}...`)
const signedRaw = await client.Document.signDocument(
protocolDoc as Parameters<typeof client.Document.signDocument>[0],
CHAIRMAN, // signer eosio name; без него signatures[].signer=undefined и
// soviet::authorize валится на «Expected string containing name».
)
// eosjs ожидает meta как string; signDocument возвращает meta как object —
// stringify, как делает desktop AuthorizeAndExecDecision.
const signedProtocol = {
...signedRaw,
meta: typeof signedRaw.meta === 'string'
? signedRaw.meta
: JSON.stringify(signedRaw.meta ?? {}),
}
// Authorize + Exec в одной транзакции (как делает desktop AuthorizeAndExecDecision).
log(`[${step.id}] authorize + exec под ${CHAIRMAN}`)
await blockchain.api.transact(
{
actions: [
{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Decisions.Authorize.actionName,
authorization: [{ actor: CHAIRMAN, permission: 'active' }],
data: {
coopname: COOPNAME,
chairman: CHAIRMAN,
decision_id: decision.id,
document: signedProtocol,
},
},
{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Decisions.Exec.actionName,
authorization: [{ actor: CHAIRMAN, permission: 'active' }],
data: {
executer: CHAIRMAN,
coopname: COOPNAME,
decision_id: decision.id,
},
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
log(`[${step.id}] ✓ принят`)
}
log('фаза 02b завершена — реальный онбординг через совет проведён')
}
@@ -1,104 +0,0 @@
/**
* Фаза 03 — мета-проекты и компоненты из `_blago/INDEX.md`.
*
* Создаёт ровно ту структуру, что и в продакшен-индексе: 12 мета-проектов
* (parent_hash = ROOT_HASH) и 30 компонентов (parent_hash = sha256 родителя).
* Хеши детерминированные — повторный прогон без reboot даёт идентичные сущности
* и идентичные скриншоты.
*
* Зависит от phase-01: программы УХД/Благорост и `capital.state`
* должны быть инициализированы.
*/
import { CapitalContract } from 'cooptypes'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
import { PROJECTS, ROOT_HASH, projectHash } from '../data/projects'
const log = (...a: unknown[]) => console.error('[seed-capital:03]', ...a)
const COOPNAME = 'voskhod'
// Локальный аналог tests/capital/getProject.ts — оригинал тащит модули с
// `import { expect } from 'vitest'`, а seed запускается под esno.
async function getProject(blockchain: Blockchain, coopname: string, projectHash: string) {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
coopname,
CapitalContract.Tables.Projects.tableName,
1000,
projectHash,
projectHash,
3,
'sha256',
)
return rows[0]
}
function makeDescription(title: string): string {
return `Демонстрационный проект «${title}» для документации Благороста.`
}
function makeMeta(id: number, title: string): string {
return JSON.stringify({
seed_source: '_blago/INDEX.md',
seed_id: id,
title,
})
}
function makeData(title: string): string {
return JSON.stringify({
summary: `Структурный элемент демонстрационного списка проектов: ${title}.`,
})
}
export async function phase03(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
// Мета должны создаваться раньше своих компонентов — иначе CAPITAL отвергнет
// компонент с неизвестным parent_hash.
const sorted = [...PROJECTS].sort((a, b) => {
if (a.parentId === null && b.parentId !== null) return -1
if (a.parentId !== null && b.parentId === null) return 1
return a.id - b.id
})
let created = 0
let skipped = 0
for (const project of sorted) {
const hash = projectHash(project.id)
const parent_hash = project.parentId === null ? ROOT_HASH : projectHash(project.parentId)
const existing = await getProject(blockchain, COOPNAME, hash)
if (existing) {
skipped++
continue
}
const data: CapitalContract.Actions.CreateProject.ICreateProject = {
coopname: COOPNAME,
project_hash: hash,
parent_hash,
title: project.title,
description: makeDescription(project.title),
invite: '',
data: makeData(project.title),
meta: makeMeta(project.id, project.title),
}
log(`#${project.id} «${project.title}» (parent=${project.parentId ?? 'root'})`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CreateProject.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
created++
}
log(`фаза 03 завершена: создано ${created}, пропущено ${skipped} (всего в seed: ${PROJECTS.length})`)
}
@@ -1,146 +0,0 @@
/**
* Фаза 04 — регистрация председателя `ant` как Contributor в Капитале.
*
* UI после адаптации редиректит любой /capital/* на CapitalRegistrationPage,
* пока у пользователя нет записи Contributor. Председатель `ant` к тому же
* входит в `temporaryStubUsernames` (whitelist «ранних участников» в desktop)
* — для него UI вообще показывает заглушку «обратитесь в поддержку», и
* пройти регистрацию через клики невозможно.
*
* Whitelist проверяется ТОЛЬКО на UI; backend GraphQL мутация
* `Mutations.Capital.CompleteCapitalRegistration` принимает запрос для любого
* username. Программно создаём Contributor для ant — и при следующем входе
* в UI его сразу пускают в Мастерскую.
*
* Эмуляция UI-флоу `signAndCompleteRegistration` из CapitalRegistrationPage.vue:
* 1) Логин председателя в controller через SDK → Bearer-token.
* 2) Mutations.Capital.GenerateCapitalRegistrationDocuments — controller
* возвращает пакет документов (storage_agreement обязательный +
* generation_contract / blagorost_agreement / generator_offer опциональные).
* 3) Каждый документ подписывается ключом ant'а через Client.Document.signDocument().
* 4) Mutations.Capital.CompleteCapitalRegistration — controller валидирует
* подписи, создаёт Contributor в БД и пушит on-chain `capital::regcontrib`.
*
* Идемпотентно: перед запуском проверяем через GraphQL, есть ли уже Contributor
* для ant. Если да — фаза no-op.
*/
import { Client, Mutations } from '@coopenomics/sdk'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:04]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const CHAIRMAN_EMAIL = 'ivanov@example.com'
interface IGeneratedDoc {
uuid?: string
hash: string
html: string
meta: Record<string, unknown> | string
binary?: string
full_title?: string
}
interface IRegDocsBundle {
generation_contract?: IGeneratedDoc
storage_agreement: IGeneratedDoc
blagorost_agreement?: IGeneratedDoc
generator_offer?: IGeneratedDoc
}
export async function phase04(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const info = await blockchain.getInfo()
const chainId = info.chain_id
const chainUrl = `${config.network.protocol}://${config.network.host}${config.network.port}`
const apiUrl = process.env.CONTROLLER_GRAPHQL_URL || 'http://127.0.0.1:2998/v1/graphql'
const wif = config.private_keys[0]
if (!wif) throw new Error('EOSIO_PRV_KEY не задан в env')
const client = Client.create({
api_url: apiUrl,
chain_url: chainUrl,
chain_id: chainId,
wif,
username: CHAIRMAN,
})
log(`логин ${CHAIRMAN_EMAIL} в controller (${apiUrl})...`)
await client.login(CHAIRMAN_EMAIL, wif)
// 1. Idempotency через попытку сгенерировать пакет: если Contributor уже зарегистрирован,
// controller вернёт пустой bundle (без обязательного storage_agreement) или бросит ошибку.
// GetContributor-query на эту проверку плохо подходит (поле username возвращается даже
// для «черновика», созданного где-то ещё), поэтому надёжнее реакция самого Generate.
log('GenerateCapitalRegistrationDocuments')
let bundle: IRegDocsBundle | undefined
try {
const genResp = await client.Mutation(
Mutations.Capital.GenerateCapitalRegistrationDocuments.mutation,
{
variables: {
data: {
coopname: COOPNAME,
username: CHAIRMAN,
lang: 'ru',
},
} as Mutations.Capital.GenerateCapitalRegistrationDocuments.IInput,
},
) as Record<string, IRegDocsBundle>
bundle = genResp[Mutations.Capital.GenerateCapitalRegistrationDocuments.name]
} catch (e) {
log(`Generate отказал — вероятно Contributor уже зарегистрирован: ${(e as Error).message ?? e}`)
log('фаза 04 завершена (no-op)')
return
}
if (!bundle?.storage_agreement) {
log(`controller вернул пустой bundle — Contributor для ${CHAIRMAN} уже зарегистрирован, пропуск`)
log('фаза 04 завершена (no-op)')
return
}
log(`получены документы: generation_contract=${!!bundle.generation_contract}, storage_agreement=true, blagorost_agreement=${!!bundle.blagorost_agreement}, generator_offer=${!!bundle.generator_offer}`)
// 3. Подписываем каждый документ ключом ant. Client.Document уже инициализирован
// с WIF после Client.login, signDocument требует только сам документ + account.
const signed: Record<string, unknown> = {}
let sigId = 1
for (const key of ['generation_contract', 'storage_agreement', 'blagorost_agreement', 'generator_offer'] as const) {
const doc = bundle[key]
if (!doc) continue
log(`подписываю ${key} (id=${sigId})`)
signed[key] = await client.Document.signDocument(
doc as Parameters<typeof client.Document.signDocument>[0],
CHAIRMAN,
sigId++,
)
}
// 4. Финализируем регистрацию. Controller внутри:
// - валидирует подписи и hash'и
// - создаёт запись Contributor в pg
// - пушит on-chain capital::regcontrib (от имени voskhod@active)
log('CompleteCapitalRegistration')
const data = {
coopname: COOPNAME,
username: CHAIRMAN,
...signed,
// Минимальные значения формы — председатель не «исполнитель», но
// controller требует наличие полей в payload.
about: 'Председатель кооператива',
rate_per_hour: '0',
hours_per_day: 0,
}
await client.Mutation(
Mutations.Capital.CompleteCapitalRegistration.mutation,
{
variables: { data } as Mutations.Capital.CompleteCapitalRegistration.IInput,
},
)
log(`Contributor зарегистрирован: ${CHAIRMAN}`)
log('фаза 04 завершена')
}
@@ -1,171 +0,0 @@
/**
* Фаза 04b — активация УХД-договора председателя через `chairmanConfirmApprove`.
*
* Phase 04 создаёт Contributor через Capital flow (Generate → Sign → Complete),
* но возвращённый row остаётся в статусе `pending` — для активации совет
* должен утвердить пакет одобрения, который Capital поставил в очередь
* (`Soviet::make_pending_approve` внутри `capital::regcontrib`). При обычном
* boot:extra (5 членов совета) утверждение идёт через стандартный flow
* голосования; для plain boot или быстрого «пропуска» одобрения по shortcut'у
* для одного председателя — этого не происходит.
*
* Без активного УХД любая попытка обновить ставку/часы/назначить мастера
* валится с «Договор УХД с пайщиком не активен».
*
* Реальный путь без хардкода:
* - читаем pending-approval из таблицы `chairman_approvals` postgres
* (parser положил туда `document` — реальный signedDocument с подписью ant
* как участника);
* - председатель добавляет ВТОРУЮ подпись (signatureId=2) к тому же
* документу через client.Document.signDocument(..., existingSignedDocuments=[doc]);
* - отправляем `Mutations.Chairman.ConfirmApprove` с approved_document =
* документ с двумя подписями. Ничего хардкоженого.
*
* Идемпотентно: если УХД уже active — фаза no-op.
*/
import { Client, Mutations } from '@coopenomics/sdk'
import { CapitalContract } from 'cooptypes'
import { Client as PgClient } from 'pg'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:04b]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const CHAIRMAN_EMAIL = 'ivanov@example.com'
interface IContributorRow {
username?: string
status?: string
contributor_hash?: string
}
interface ISignedDocument {
version: string
hash: string
doc_hash: string
meta_hash: string
meta: unknown
signatures: Array<{
id: number
signed_hash: string
signer: string
public_key: string
signature: string
signed_at: string
meta: unknown
}>
}
async function fetchApprovalDocument(approvalHash: string): Promise<ISignedDocument | null> {
const pg = new PgClient({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
user: process.env.POSTGRES_USERNAME,
password: process.env.POSTGRES_PASSWORD,
database: COOPNAME,
})
await pg.connect()
try {
const r = await pg.query<{ document: ISignedDocument; status: string }>(
`SELECT document, status FROM chairman_approvals WHERE approval_hash = $1 LIMIT 1`,
[approvalHash.toLowerCase()],
)
return r.rows[0]?.document ?? null
} finally {
await pg.end()
}
}
export async function phase04b(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'contributors',
1,
CHAIRMAN,
CHAIRMAN,
2,
'i64',
) as IContributorRow[]
const row = rows[0]
if (!row) {
throw new Error(`Contributor ${CHAIRMAN} не найден — сначала запусти phase 04`)
}
if (row.status === 'active') {
log(`УХД-договор ${CHAIRMAN} уже active — пропуск`)
return
}
if (!row.contributor_hash) {
throw new Error(`У ${CHAIRMAN} нет contributor_hash в строке ${JSON.stringify(row)}`)
}
const info = await blockchain.getInfo()
const chainId = info.chain_id
const chainUrl = `${config.network.protocol}://${config.network.host}${config.network.port}`
const apiUrl = process.env.CONTROLLER_GRAPHQL_URL || 'http://127.0.0.1:2998/v1/graphql'
const wif = config.private_keys[0]
if (!wif) throw new Error('EOSIO_PRV_KEY не задан в env')
const client = Client.create({
api_url: apiUrl,
chain_url: chainUrl,
chain_id: chainId,
wif,
username: CHAIRMAN,
})
log(`логин ${CHAIRMAN_EMAIL} в controller (${apiUrl})...`)
await client.login(CHAIRMAN_EMAIL, wif)
// Ждём пока parser положит approval в pg + добываем оригинальный document
// (signedDocument с ant'овой подписью id=1).
let approvalDoc: ISignedDocument | null = null
for (let attempt = 1; attempt <= 8; attempt++) {
await new Promise(r => setTimeout(r, attempt === 1 ? 1500 : 2000))
log(`fetch chairman_approvals.document (approval_hash=${row.contributor_hash.slice(0, 10)}...) attempt=${attempt}`)
approvalDoc = await fetchApprovalDocument(row.contributor_hash)
if (approvalDoc) break
log('approval ещё не в pg — ждём parser...')
}
if (!approvalDoc) {
throw new Error(`approval с hash=${row.contributor_hash} не появился в chairman_approvals за 15s`)
}
// Председатель добавляет ВТОРУЮ подпись к тому же документу — ровно как
// делает desktop ConfirmApproval.useSignDocument с signatureId=2.
log(`подписываю document второй подписью (signatureId=2) от имени ${CHAIRMAN}`)
const generated = {
hash: approvalDoc.doc_hash,
meta: approvalDoc.meta,
}
const signedTwice = await client.Document.signDocument(
generated as Parameters<typeof client.Document.signDocument>[0],
CHAIRMAN,
2,
[approvalDoc] as Parameters<typeof client.Document.signDocument>[3],
)
// controller'ский resolver ждёт meta как string в SignedDigitalDocumentInput.
const approvedDocumentForApi = {
...signedTwice,
meta: typeof signedTwice.meta === 'string'
? signedTwice.meta
: JSON.stringify(signedTwice.meta ?? {}),
}
log(`chairmanConfirmApprove (approval_hash=${row.contributor_hash.slice(0, 10)}..., signatures=${approvedDocumentForApi.signatures.length})`)
await client.Mutation(Mutations.Chairman.ConfirmApprove.mutation, {
variables: {
data: {
coopname: COOPNAME,
approval_hash: row.contributor_hash.toLowerCase(),
approved_document: approvedDocumentForApi,
},
} as Mutations.Chairman.ConfirmApprove.IInput,
})
log(`фаза 04b завершена — УХД-договор ${CHAIRMAN} активен`)
}
@@ -1,171 +0,0 @@
/**
* Фаза 05 — регистрация дополнительных пайщиков как Contributor в Капитале.
*
* Для серии инструкций по «Генерации» нужны минимум три участника:
* `ant` (председатель, регистрируется фазой 04) + `ivanpetrov` + `ekaterina`.
* Они будут авторами/исполнителями компонента и голосуют по Водянову друг
* за друга при приёмке результата.
*
* Фаза идёт по тому же пути, что и `04-contributor.ts`, но параметризованно:
* для каждого пайщика — Generate → Sign → Complete. Идемпотентна (Generate
* на уже зарегистрированного отдаёт пустой bundle или ошибку).
*
* WIF и email пайщиков читаются из `components/docs-harness/state/participants/<u>.json`
* — единый источник истины для тестовых фикстур (см. orchestrator
* `components/docs-harness/bin/shoot.mjs`). Фикстуры создаёт orchestrator
* через `add-plain-participant.ts` до запуска seed-фаз.
*/
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { Client, Mutations } from '@coopenomics/sdk'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:05]', ...a)
const COOPNAME = 'voskhod'
const here = path.dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = path.resolve(here, '../../../../../..')
const STATE_DIR = path.join(REPO_ROOT, 'components/docs-harness/state/participants')
interface IFixture {
username: string
email: string
wif: string
}
function readFixture(username: string): IFixture {
const file = path.join(STATE_DIR, `${username}.json`)
if (!fs.existsSync(file)) {
throw new Error(
`Фикстура «${username}» не найдена: ${file}. ` +
`Перед запуском фазы 05 orchestrator (bin/shoot.mjs) должен создать её через add-plain-participant. ` +
`Если ты запускаешь фазу руками — добавь имя в meta.fixtures сценария или создай файл через add-plain-participant.ts вручную.`,
)
}
const data = JSON.parse(fs.readFileSync(file, 'utf8')) as { username?: string; email?: string; wif?: string }
if (!data.wif || !data.email) {
throw new Error(`Фикстура ${file}: отсутствует wif или email. Содержимое: ${JSON.stringify(data)}`)
}
return { username, email: data.email, wif: data.wif }
}
interface IGeneratedDoc {
uuid?: string
hash: string
html: string
meta: Record<string, unknown> | string
binary?: string
full_title?: string
}
interface IRegDocsBundle {
generation_contract?: IGeneratedDoc
storage_agreement: IGeneratedDoc
blagorost_agreement?: IGeneratedDoc
generator_offer?: IGeneratedDoc
}
interface IContributorProfile {
about: string
rate_per_hour: string
hours_per_day: number
}
const PROFILES: Record<string, IContributorProfile> = {
ivanpetrov: { about: 'Разработчик. Полный стек.', rate_per_hour: '1500', hours_per_day: 8 },
ekaterina: { about: 'Дизайнер интерфейсов и проектировщик.', rate_per_hour: '1200', hours_per_day: 6 },
}
async function registerContributor(
fix: IFixture,
apiUrl: string,
chainUrl: string,
chainId: string,
): Promise<void> {
const client = Client.create({
api_url: apiUrl,
chain_url: chainUrl,
chain_id: chainId,
wif: fix.wif,
username: fix.username,
})
log(`логин ${fix.email} (${fix.username})...`)
await client.login(fix.email, fix.wif)
log(`GenerateCapitalRegistrationDocuments → ${fix.username}`)
let bundle: IRegDocsBundle | undefined
try {
const genResp = await client.Mutation(
Mutations.Capital.GenerateCapitalRegistrationDocuments.mutation,
{
variables: {
data: {
coopname: COOPNAME,
username: fix.username,
lang: 'ru',
},
} as Mutations.Capital.GenerateCapitalRegistrationDocuments.IInput,
},
) as Record<string, IRegDocsBundle>
bundle = genResp[Mutations.Capital.GenerateCapitalRegistrationDocuments.name]
} catch (e) {
log(`Generate отказал для ${fix.username} — вероятно уже зарегистрирован: ${(e as Error).message ?? e}`)
return
}
if (!bundle?.storage_agreement) {
log(`controller вернул пустой bundle для ${fix.username} — пропуск`)
return
}
const signed: Record<string, unknown> = {}
let sigId = 1
for (const key of ['generation_contract', 'storage_agreement', 'blagorost_agreement', 'generator_offer'] as const) {
const doc = bundle[key]
if (!doc) continue
log(`подписываю ${key} (${fix.username}, id=${sigId})`)
signed[key] = await client.Document.signDocument(
doc as Parameters<typeof client.Document.signDocument>[0],
fix.username,
sigId++,
)
}
const profile = PROFILES[fix.username] ?? { about: '', rate_per_hour: '0', hours_per_day: 0 }
log(`CompleteCapitalRegistration → ${fix.username}`)
await client.Mutation(
Mutations.Capital.CompleteCapitalRegistration.mutation,
{
variables: {
data: {
coopname: COOPNAME,
username: fix.username,
...signed,
about: profile.about,
rate_per_hour: profile.rate_per_hour,
hours_per_day: profile.hours_per_day,
},
} as Mutations.Capital.CompleteCapitalRegistration.IInput,
},
)
log(`Contributor зарегистрирован: ${fix.username}`)
}
export async function phase05(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const info = await blockchain.getInfo()
const chainId = info.chain_id
const chainUrl = `${config.network.protocol}://${config.network.host}${config.network.port}`
const apiUrl = process.env.CONTROLLER_GRAPHQL_URL || 'http://127.0.0.1:2998/v1/graphql'
for (const username of ['ivanpetrov', 'ekaterina']) {
const fix = readFixture(username)
await registerContributor(fix, apiUrl, chainUrl, chainId)
}
log('фаза 05 завершена')
}
@@ -1,89 +0,0 @@
/**
* Фаза 06 — создаёт проект «Кошелёк пайщика» (id=48) и его компонент «MVP v1»
* (id=49) ончейн через `capital::createproject`.
*
* Это «рабочий» проект для серии инструкций по Генерации: на нём документируется
* полный путь — назначение мастера, план, инвестиции, коммиты, голосование,
* выход в Благорост. В обычной серии фаза 03 (полный реестр из 12+1 проектов)
* не подключается — Мастерская содержит только этот проект, чтобы документация
* не путалась с фоном.
*
* Хеши детерминированные (sha256("blago:project:<id>")) — те же что и в фазе 03,
* так что одновременный запуск 03 и 06 не создаст дублей: оба используют один
* и тот же hash для id=48 и id=49.
*/
import { CapitalContract } from 'cooptypes'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
import { PROJECTS, ROOT_HASH, projectHash } from '../data/projects'
const log = (...a: unknown[]) => console.error('[seed-capital:06]', ...a)
const COOPNAME = 'voskhod'
const PROJECT_ID = 48
const COMPONENT_ID = 49
async function getProject(blockchain: Blockchain, coopname: string, projectHashHex: string) {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
coopname,
CapitalContract.Tables.Projects.tableName,
1000,
projectHashHex,
projectHashHex,
3,
'sha256',
)
return rows[0]
}
export async function phase06(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const targets = PROJECTS.filter((p) => p.id === PROJECT_ID || p.id === COMPONENT_ID)
if (targets.length !== 2) {
throw new Error(`data/projects.ts: ожидаются id=${PROJECT_ID} и id=${COMPONENT_ID}, найдено ${targets.length}`)
}
// Проект должен идти раньше компонента, иначе capital отклонит компонент
// с неизвестным parent_hash.
const sorted = targets.sort((a, b) => (a.parentId === null ? -1 : 1) - (b.parentId === null ? -1 : 1))
let created = 0
let skipped = 0
for (const item of sorted) {
const hash = projectHash(item.id)
const parent_hash = item.parentId === null ? ROOT_HASH : projectHash(item.parentId)
const existing = await getProject(blockchain, COOPNAME, hash)
if (existing) {
log(`#${item.id} «${item.title}» уже существует — пропуск`)
skipped++
continue
}
const data: CapitalContract.Actions.CreateProject.ICreateProject = {
coopname: COOPNAME,
project_hash: hash,
parent_hash,
title: item.title,
description: `Кооперативный проект «${item.title}».`,
invite: '',
data: JSON.stringify({ summary: item.title }),
meta: JSON.stringify({ seed_source: 'seed-capital/phase-06', seed_id: item.id, title: item.title }),
}
log(`#${item.id} «${item.title}» (parent=${item.parentId ?? 'root'})`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CreateProject.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
created++
}
log(`фаза 06 завершена: создано ${created}, пропущено ${skipped}`)
}
@@ -1,292 +0,0 @@
/**
* Фаза 07 — назначает мастера компонента «MVP v1» и устанавливает план через SDK.
*
* Председатель `ant` (он же создатель компонента) становится мастером, после чего
* ставится план: 160 ч исполнителей × 1500 ₽/ч + 50 000 ₽ дополнительных расходов.
*
* После этой фазы:
* - is_planed = true → переключатель «Принимает инвестиции» на компоненте
* можно включить;
* - вкладки «План» компонента и проекта показывают агрегированные значения.
*
* Зависит от фазы 06 (проект и компонент должны существовать). Идемпотентна
* через query GetProjectWithRelations: если master уже = ant и is_planed = true,
* фаза становится no-op.
*/
import { Client, Mutations, Queries } from '@coopenomics/sdk'
import { CapitalContract, SovietContract } from 'cooptypes'
import { createHash } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:07]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const CHAIRMAN_EMAIL = 'ivanov@example.com'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
// План — реалистичные значения для документации.
// 160 ч × 5000 ₽ = 800 000 ₽ за время исполнителей; + 50 000 ₽ доп. расходов.
const PLAN_HOURS = 160
const PLAN_HOUR_COST = '5000.0000 RUB'
const PLAN_EXPENSES = '50000.0000 RUB'
interface IProjectState {
master?: string
is_planed?: boolean
}
// fakeDocument-обёртка для approved_document — соответствует
// тестовому шаблону `tests/shared/fakeDocument.ts`. Председатель `ant`
// в signatures[0].signer.
const FAKE_HASH = '157192b276da23cc84ab078fc8755c051c5f0430bf4802e55718221e6b76c777'
function fakeDocumentSignedBy(signer: string) {
return {
version: '1.0.0',
hash: FAKE_HASH,
doc_hash: FAKE_HASH,
meta_hash: FAKE_HASH,
meta: '{}',
signatures: [{
id: 1,
signed_hash: FAKE_HASH,
signer,
public_key: 'EOS5JhMfxbsNebajHcTEK8yC9uNN9Dit9hEmzE8ri8yMhhzxrLg3J',
signature: 'SIG_K1_KmKWPBC8dZGGDGhbKEoZEzPr3h5crRrR2uLdGRF5DJbeibY1MY1bZ9sPwHsgmPfiGFv9psfoCVsXFh9TekcLuvaeuxRKA8',
signed_at: '2025-05-14T12:22:26',
meta: '{}',
}],
}
}
interface IContributorRow {
username?: string
status?: string
contributor_hash?: string
contract?: { hash?: string }
}
// УХД-договор председателя должен быть в статусе `active`, иначе capital::setmaster
// падает с «Основной договор УХД не активен». В контракт-тестах этот переход
// делается через soviet::confirmapprv (см. tests/capital/processApprove.ts).
// CompleteCapitalRegistration через GraphQL оставляет status=pending, поэтому
// approve приходится проводить отдельно.
async function approveContributorContract(
blockchain: Blockchain,
username: string,
): Promise<void> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'contributors',
1,
username,
username,
2,
'i64',
) as IContributorRow[]
const row = rows[0]
if (!row) throw new Error(`Contributor ${username} не найден в таблице capital::contributors`)
if (row.status === 'active') {
log(`УХД-договор ${username} уже active — пропуск approve`)
return
}
// approval_hash в soviet::approvals = contributor_hash, не contract.hash
// (см. capital::regcontrib → Soviet::make_pending_approve(..., contributor_hash, ...))
const approvalHash = row.contributor_hash
if (!approvalHash) throw new Error(`У contributor ${username} нет contributor_hash`)
log(`soviet::confirmapprv ${username} (approval_hash=${approvalHash.slice(0, 10)}...)`)
await blockchain.api.transact({
actions: [{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Approves.ConfirmApprove.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username: CHAIRMAN, // approver — председатель
approval_hash: approvalHash,
approved_document: fakeDocumentSignedBy(CHAIRMAN),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
// Подпись приложения к проекту/компоненту: capital::getclearance + soviet::confirmapprv
// (см. tests/capital/signAppendix.ts). После этого contributor.appendixes содержит
// projectHash, и пайщик считается «участником проекта», что требуется для setMaster
// и других ролевых действий.
//
// appendix_hash — детерминированный хеш для идемпотентности (sha256 от username:projectHash:tag).
function makeAppendixHash(username: string, projectHash: string, tag: string): string {
return createHash('sha256').update(`appendix:${username}:${projectHash}:${tag}`).digest('hex')
}
async function signAppendixIfNeeded(
blockchain: Blockchain,
username: string,
projectHash: string,
tag: string,
): Promise<void> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'contributors',
1,
username,
username,
2,
'i64',
) as IContributorRow[]
const row = rows[0] as IContributorRow & { appendixes?: string[] }
if (!row) throw new Error(`Contributor ${username} не найден`)
if (row.appendixes?.includes(projectHash)) {
log(`${username} уже подписал приложение к проекту/компоненту (${projectHash.slice(0, 10)}...) — пропуск`)
return
}
const appendixHash = makeAppendixHash(username, projectHash, tag)
log(`capital::getclearance ${username}${projectHash.slice(0, 10)}...`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.GetClearance.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
project_hash: projectHash,
appendix_hash: appendixHash,
document: fakeDocumentSignedBy(username),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log(`soviet::confirmapprv приложение ${username} (${appendixHash.slice(0, 10)}...)`)
await blockchain.api.transact({
actions: [{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Approves.ConfirmApprove.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username: CHAIRMAN,
approval_hash: appendixHash,
approved_document: fakeDocumentSignedBy(CHAIRMAN),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
export async function phase07(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
// Активируем УХД-договоры всех трёх участников — нужно для:
// - председателя (setMaster, setPlan)
// - ivanpetrov / ekaterina (для будущих фаз: инвестирование, коммиты, голосование).
// Идемпотентно: если status уже active, approve пропускается.
for (const username of [CHAIRMAN, 'ivanpetrov', 'ekaterina']) {
await approveContributorContract(blockchain, username)
}
// Председатель должен подписать приложение к компоненту, чтобы стать «участником»,
// иначе setMaster падает с «Мастер должен быть участником проекта».
await signAppendixIfNeeded(blockchain, CHAIRMAN, COMPONENT_HASH, 'mvp-v1')
const info = await blockchain.getInfo()
const chainId = info.chain_id
const chainUrl = `${config.network.protocol}://${config.network.host}${config.network.port}`
const apiUrl = process.env.CONTROLLER_GRAPHQL_URL || 'http://127.0.0.1:2998/v1/graphql'
const wif = config.private_keys[0]
if (!wif) throw new Error('EOSIO_PRV_KEY не задан в env')
const client = Client.create({
api_url: apiUrl,
chain_url: chainUrl,
chain_id: chainId,
wif,
username: CHAIRMAN,
})
log(`логин ${CHAIRMAN_EMAIL}...`)
await client.login(CHAIRMAN_EMAIL, wif)
// Поллинг до 60с пока parser не индексирует createproject от фазы 06.
// После reboot:extra controller-БД пуста, индексация занимает ~3-10с.
// Без этого SetPlan падает с «Проект не найден» — мутация делает
// валидацию через repository ещё до отправки on-chain.
let current: IProjectState = {}
for (let attempt = 0; attempt < 30; attempt++) {
try {
const resp = await client.Query(Queries.Capital.GetProjectWithRelations.query, {
variables: {
// GetProjectWithRelationsInput: только projectHash (camelCase),
// в отличие от SetMasterInput/SetPlanInput, которые принимают
// coopname+project_hash. Несимметрия в backend GraphQL.
data: { projectHash: COMPONENT_HASH },
} as Queries.Capital.GetProjectWithRelations.IInput,
}) as Record<string, IProjectState | null>
const got = resp[Queries.Capital.GetProjectWithRelations.name]
if (got && (got.master !== undefined || got.is_planed !== undefined)) {
current = got
log(`parser индексировал проект (attempt ${attempt + 1})`)
break
}
// Ответ есть, но без полей — проект ещё не догнан.
if (attempt < 29) {
await new Promise((r) => setTimeout(r, 2000))
continue
}
log('parser не индексировал проект за 60с — продолжаем без current state')
} catch (e) {
// GraphQL «Проект не найден» — ждём parser. Любая иная ошибка — break.
const raw = JSON.stringify(e).toLowerCase()
if (raw.includes('не найден') && attempt < 29) {
if (attempt % 5 === 0) log(`parser ещё не догнал createproject — ждём 2с (${attempt + 1}/30)`)
await new Promise((r) => setTimeout(r, 2000))
continue
}
log(`Query GetProjectWithRelations упал не на «не найден»: ${raw.slice(0, 200)}`)
break
}
}
if (current.master !== CHAIRMAN) {
log(`SetMaster → master=${CHAIRMAN} для компонента MVP v1`)
await client.Mutation(Mutations.Capital.SetMaster.mutation, {
variables: {
data: {
coopname: COOPNAME,
master: CHAIRMAN,
project_hash: COMPONENT_HASH,
},
} as Mutations.Capital.SetMaster.IInput,
})
} else {
log(`master уже ${CHAIRMAN} — пропуск SetMaster`)
}
if (!current.is_planed) {
log(`SetPlan → ${PLAN_HOURS} ч × ${PLAN_HOUR_COST} + ${PLAN_EXPENSES} расходов`)
const planResp = await client.Mutation(Mutations.Capital.SetPlan.mutation, {
variables: {
data: {
coopname: COOPNAME,
master: CHAIRMAN,
project_hash: COMPONENT_HASH,
plan_creators_hours: PLAN_HOURS,
plan_hour_cost: PLAN_HOUR_COST,
plan_expenses: PLAN_EXPENSES,
},
} as Mutations.Capital.SetPlan.IInput,
}) as Record<string, IProjectState | null>
const updated = planResp[Mutations.Capital.SetPlan.name]
log(`SetPlan response: master=${updated?.master}, is_planed=${updated?.is_planed}`)
} else {
log('is_planed уже true — пропуск SetPlan')
}
log('фаза 07 завершена')
}
@@ -1,180 +0,0 @@
/**
* Фаза 07b — массовый автодопуск.
*
* Подписывает приложение к УХД (`getclearance` + `confirmapprv`) от лица
* каждого пайщика-участника к Проекту-48 и Компоненту-49. После этой фазы
* у всех трёх контрибьюторов (ant / ivanpetrov / ekaterina) подтверждён
* допуск к обоим — пайщик считается «участником», `permissions.has_clearance`
* на UI становится true, FAB на странице раскрывает действия (создать
* артефакт, задачу и т.д.), голос фактически уходит в blockchain.
*
* Идемпотентно: signAppendixIfNeeded проверяет contributor.appendixes и
* пропускает, если допуск уже есть.
*
* Зависит от фазы 07 (создан мастер + план; УХД-договоры всех пайщиков
* `active`).
*/
import { CapitalContract, SovietContract } from 'cooptypes'
import { createHash } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:07b]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const PROJECT_HASH = createHash('sha256').update('blago:project:48').digest('hex')
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
// Все контрибьюторы, которым нужен допуск к проекту и компоненту.
const PARTICIPANTS = ['ant', 'ivanpetrov', 'ekaterina'] as const
const FAKE_HASH = '157192b276da23cc84ab078fc8755c051c5f0430bf4802e55718221e6b76c777'
function fakeDocumentSignedBy(signer: string) {
return {
version: '1.0.0',
hash: FAKE_HASH,
doc_hash: FAKE_HASH,
meta_hash: FAKE_HASH,
meta: '{}',
signatures: [{
id: 1,
signed_hash: FAKE_HASH,
signer,
public_key: 'EOS5JhMfxbsNebajHcTEK8yC9uNN9Dit9hEmzE8ri8yMhhzxrLg3J',
signature: 'SIG_K1_KmKWPBC8dZGGDGhbKEoZEzPr3h5crRrR2uLdGRF5DJbeibY1MY1bZ9sPwHsgmPfiGFv9psfoCVsXFh9TekcLuvaeuxRKA8',
signed_at: '2025-05-14T12:22:26',
meta: '{}',
}],
}
}
interface IContributorRow {
username?: string
status?: string
appendixes?: string[]
}
function makeAppendixHash(username: string, projectHash: string, tag: string): string {
return createHash('sha256').update(`appendix:${username}:${projectHash}:${tag}`).digest('hex')
}
async function signAppendixIfNeeded(
blockchain: Blockchain,
username: string,
projectHash: string,
tag: string,
): Promise<void> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'contributors',
1,
username,
username,
2,
'i64',
) as IContributorRow[]
const row = rows[0]
if (!row) throw new Error(`Contributor ${username} не найден`)
if (row.appendixes?.map((h) => h.toLowerCase()).includes(projectHash.toLowerCase())) {
log(`${username}${projectHash.slice(0, 10)}... уже есть допуск, пропуск`)
return
}
const appendixHash = makeAppendixHash(username, projectHash, tag)
log(`capital::getclearance ${username}${projectHash.slice(0, 10)}...`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.GetClearance.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
project_hash: projectHash,
appendix_hash: appendixHash,
document: fakeDocumentSignedBy(username),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
// SHIP эмиттит дельту contract_row как net state change между блоками:
// если строка вставлена и удалена внутри ОДНОГО блока — дельты не будет.
// apprvappndx сразу же стирает appendix через delete_appendix(); если он
// попадёт в тот же блок что и getclearance — parser не увидит ни insert,
// ни delete, и UI/seed-await будут считать что допуск не получен.
//
// Эмпирически 700ms (1×block) не хватало: ~1 раз из 6 строка в
// capital_appendixes теряется, мастер на UI видит «Принять участие» вместо
// действий. 1500ms (≥3×block) разносит actions гарантированно.
// См. memory project_parser_loses_appendix_deltas.md.
await new Promise((resolve) => setTimeout(resolve, 1500))
log(`soviet::confirmapprv приложение ${username} (${appendixHash.slice(0, 10)}...)`)
await blockchain.api.transact({
actions: [{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Approves.ConfirmApprove.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username: CHAIRMAN,
approval_hash: appendixHash,
approved_document: fakeDocumentSignedBy(CHAIRMAN),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
export async function phase07b(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
// Подписываем приложения для всех пайщиков к обоим хешам — проекту и компоненту.
for (const username of PARTICIPANTS) {
for (const [hash, tag] of [[PROJECT_HASH, 'project-48'], [COMPONENT_HASH, 'mvp-v1']] as const) {
await signAppendixIfNeeded(blockchain, username, hash, tag)
}
}
// Дельты `capital::appendixes` индексируются parser'ом с заметной задержкой
// (race с action `apprvappndx`). UI считает has_clearance по этой таблице,
// поэтому без полной индексации последующие сценарии будут видеть FAB
// «Принять участие» вместо рабочих действий. Ждём пока все 6 строк появятся
// в БД (3 пайщика × 2 hash) — но не дольше 90s.
const expected = PARTICIPANTS.length * 2 // 3 × 2 = 6
log(`жду индексации ${expected} appendix'ов в capital_appendixes...`)
const pg = new (await import('pg')).Client({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
user: process.env.POSTGRES_USERNAME,
password: process.env.POSTGRES_PASSWORD,
database: COOPNAME,
})
await pg.connect()
try {
const deadline = Date.now() + 90_000
let lastCount = 0
while (Date.now() < deadline) {
const r = await pg.query(
`SELECT count(*)::int AS c FROM capital_appendixes WHERE status = 'confirmed'`,
)
const c = r.rows[0]?.c ?? 0
if (c !== lastCount) {
log(`capital_appendixes confirmed: ${c}/${expected}`)
lastCount = c
}
if (c >= expected) break
await new Promise((resolve) => setTimeout(resolve, 1500))
}
if (lastCount < expected) {
log(`предупреждение: только ${lastCount}/${expected} appendix'ов проиндексированы за 90s`)
}
} finally {
await pg.end()
}
log('фаза 07b завершена')
}
@@ -1,361 +0,0 @@
/**
* Фаза 08 — инвестиции: открывает приём инвестиций на компоненте, проводит
* два паевых взноса от пайщиков-инвесторов:
*
* 1) ivanpetrov → компонент «MVP v1» (15 000 ₽, прямая аллокация в компонент)
* 2) ekaterina → программа «Благорост» (10 000 ₽, без указания компонента;
* совет сам распределит средства)
*
* Объёмы намеренно небольшие: при крупных инвестициях active_voting_amount
* раздувается, и суммарный intellectual_cost после расчёта голосов
* превосходит пул кошелька «ЦПП Генератор — принятый коммит». Маленькие
* инвестиции дают пропорции, при которых выкуп РИД (фаза 13) проходит.
*
* Перед инвестициями оба пайщика пополняют свои кошельки через
* wallet::createdeposit + gateway::completeincome.
*
* Зависит от фазы 07 (план должен быть установлен — иначе capital::openproject
* не пропустит). Идемпотентна: openproject пропускается если is_opened=true,
* deposit/invest всегда создают новые записи (но это не критично для seed).
*/
import path from 'node:path'
import fs from 'node:fs'
import { createHash, randomInt } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { CapitalContract, WalletContract, GatewayContract, SovietContract } from 'cooptypes'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:08]', ...a)
const COOPNAME = 'voskhod'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
const here = path.dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = path.resolve(here, '../../../../../..')
const STATE_DIR = path.join(REPO_ROOT, 'components/docs-harness/state/participants')
const FAKE_HASH = '157192b276da23cc84ab078fc8755c051c5f0430bf4802e55718221e6b76c777'
function fakeDocumentSignedBy(signer: string) {
return {
version: '1.0.0',
hash: FAKE_HASH,
doc_hash: FAKE_HASH,
meta_hash: FAKE_HASH,
meta: '{}',
signatures: [{
id: 1,
signed_hash: FAKE_HASH,
signer,
public_key: 'EOS5JhMfxbsNebajHcTEK8yC9uNN9Dit9hEmzE8ri8yMhhzxrLg3J',
signature: 'SIG_K1_KmKWPBC8dZGGDGhbKEoZEzPr3h5crRrR2uLdGRF5DJbeibY1MY1bZ9sPwHsgmPfiGFv9psfoCVsXFh9TekcLuvaeuxRKA8',
signed_at: '2025-05-14T12:22:26',
meta: '{}',
}],
}
}
function rndHash(): string {
return createHash('sha256').update(`rnd:${Date.now()}:${randomInt(1_000_000_000)}`).digest('hex')
}
interface IFixture { username: string; email: string; wif: string }
function readFixture(username: string): IFixture {
const file = path.join(STATE_DIR, `${username}.json`)
const data = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial<IFixture>
if (!data.wif || !data.email) throw new Error(`Фикстура ${file} без wif/email`)
return { username, email: data.email, wif: data.wif }
}
async function depositToWallet(blockchain: Blockchain, username: string, amountRub: number): Promise<void> {
const depositHash = rndHash()
const quantity = `${amountRub.toFixed(4)} RUB`
log(`wallet::createdeposit ${username}${quantity} (deposit=${depositHash.slice(0, 10)}...)`)
await blockchain.api.transact({
actions: [{
account: WalletContract.contractName.production,
name: WalletContract.Actions.CreateDeposit.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
deposit_hash: depositHash,
quantity,
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log(`gateway::completeincome ${depositHash.slice(0, 10)}...`)
await blockchain.api.transact({
actions: [{
account: GatewayContract.contractName.production,
name: GatewayContract.Actions.CompleteIncome.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
income_hash: depositHash,
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function startProjectIfNeeded(blockchain: Blockchain): Promise<void> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'projects',
1,
COMPONENT_HASH,
COMPONENT_HASH,
3,
'sha256',
) as Array<{ status?: string }>
const row = rows[0]
// startproject допустим только из pending; если уже active/voting/result/finalized — no-op.
if (row?.status && row.status !== 'pending') {
log(`компонент в статусе ${row.status} — пропуск capital::startproject`)
return
}
log('capital::startproject — переводим компонент в Active')
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.StartProject.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function openProjectIfNeeded(blockchain: Blockchain): Promise<void> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'projects',
1,
COMPONENT_HASH,
COMPONENT_HASH,
3,
'sha256',
) as Array<{ is_opened?: number }>
const row = rows[0]
if (row?.is_opened === 1) {
log('компонент уже открыт на инвестиции — пропуск capital::openproject')
return
}
log('capital::openproject — компонент готов принимать инвестиции')
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.OpenProject.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function investInProject(blockchain: Blockchain, investor: IFixture, amountRub: number): Promise<void> {
const investHash = rndHash()
const amount = `${amountRub.toFixed(4)} RUB`
log(`capital::createinvest ${investor.username} → компонент MVP v1 (${amount})`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CreateProjectInvest.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
username: investor.username,
invest_hash: investHash,
amount,
statement: fakeDocumentSignedBy(investor.username),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function investInProgram(blockchain: Blockchain, investor: IFixture, amountRub: number): Promise<void> {
const investHash = rndHash()
const amount = `${amountRub.toFixed(4)} RUB`
log(`capital::createpinv ${investor.username} → программа «Благорост» (${amount})`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CreateProgramInvest.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username: investor.username,
invest_hash: investHash,
amount,
statement: fakeDocumentSignedBy(investor.username),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
// Подпись соглашения о ЦПП (wallet/blagorost) от имени самого пайщика —
// soviet::sndagreement требует authorization=username, поэтому в keychain
// blockchain нужны wif'ы инвесторов.
async function signAgreement(
blockchain: Blockchain,
username: string,
agreementType: string,
): Promise<void> {
log(`soviet::sndagreement ${username} ← '${agreementType}'`)
await blockchain.api.transact({
actions: [{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Agreements.SendAgreement.actionName,
authorization: [{ actor: username, permission: 'active' }],
data: {
coopname: COOPNAME,
administrator: COOPNAME,
username,
agreement_type: agreementType,
document: fakeDocumentSignedBy(username),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function signAppendixIfNeeded(blockchain: Blockchain, username: string): Promise<void> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'contributors',
1,
username,
username,
2,
'i64',
) as Array<{ appendixes?: string[] }>
if (rows[0]?.appendixes?.includes(COMPONENT_HASH)) {
log(`${username} уже подписал приложение к компоненту — пропуск`)
return
}
const appendixHash = createHash('sha256').update(`appendix:${username}:${COMPONENT_HASH}:invest`).digest('hex')
log(`capital::getclearance ${username} → компонент MVP v1`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.GetClearance.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
project_hash: COMPONENT_HASH,
appendix_hash: appendixHash,
document: fakeDocumentSignedBy(username),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log(`soviet::confirmapprv приложение ${username} (${appendixHash.slice(0, 10)}...)`)
await blockchain.api.transact({
actions: [{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Approves.ConfirmApprove.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username: 'ant',
approval_hash: appendixHash,
approved_document: fakeDocumentSignedBy('ant'),
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture): Promise<void> {
// Кошелёк по программе wallet (id=1) обязателен для completeincome.
const walletRows = await blockchain.getTableRows(
WalletContract.contractName.production,
COOPNAME,
'userwallets',
1,
fix.username,
fix.username,
2,
'i64',
).catch(() => [])
if (walletRows.length === 0) {
await signAgreement(blockchain, fix.username, 'wallet')
} else {
log(`${fix.username}: wallet-кошелёк уже есть — пропуск signWalletAgreement`)
}
// Базовые agreements первого входа — убирают каскад модалок «Прочитайте и
// подпишите документ» в UI (route.meta.agreements = ['wallet','signature','privacy','user']).
// Без них любая страница desktop при первом логине показывает 4 диалога.
for (const aType of ['signature', 'privacy', 'user']) {
await signAgreement(blockchain, fix.username, aType).catch((e) => {
log(`signAgreement ${aType} для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
})
}
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
})
}
export async function phase08(): Promise<void> {
const petrov = readFixture('ivanpetrov')
const ekaterina = readFixture('ekaterina')
// Создаём blockchain с расширенным keychain — ключи всех нужных подписантов
// (soviet::sndagreement требует подписи самого пайщика).
const blockchain = new Blockchain(config.network, [
...config.private_keys,
petrov.wif,
ekaterina.wif,
])
await blockchain.update_pass_instance()
// Переводим компонент в active (нужно для createinvest) и открываем на приём инвестиций
await startProjectIfNeeded(blockchain)
await openProjectIfNeeded(blockchain)
// Соглашения и кошельки инвесторов
await ensureAgreementsAndWallet(blockchain, petrov)
await ensureAgreementsAndWallet(blockchain, ekaterina)
// Приложения к УХД-договору для инвесторов (нужно для capital::createinvest)
await signAppendixIfNeeded(blockchain, petrov.username)
await signAppendixIfNeeded(blockchain, ekaterina.username)
// Идемпотентность: createinvest/createpinv требуют project.status = active.
// На повторном прогоне (компонент уже в voting/result) — пропускаем deposit
// и инвестиции; первая запись инвестиции уже есть в системе.
const componentRows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'projects',
1,
COMPONENT_HASH,
COMPONENT_HASH,
3,
'sha256',
) as Array<{ status?: string }>
if (componentRows[0]?.status !== 'active') {
log(`компонент в статусе ${componentRows[0]?.status} — пропуск депозитов и инвестиций (фаза 08 уже отработала ранее)`)
log('фаза 08 завершена')
return
}
// Пополняем кошельки инвесторов через депозит
await depositToWallet(blockchain, petrov.username, 20_000)
await depositToWallet(blockchain, ekaterina.username, 15_000)
// Инвестиции
await investInProject(blockchain, petrov, 15_000)
await investInProgram(blockchain, ekaterina, 10_000)
log('фаза 08 завершена')
}
@@ -1,152 +0,0 @@
/**
* Фаза 09 — задачи (issues) в компоненте «MVP v1».
*
* Создаёт через controller GraphQL (capitalCreateIssue) набор задач разных
* статусов от имени мастера `ant`:
* 1) ivanpetrov, 30 ч, status=DONE («выполнена») → 30 ч available_hours для коммита
* 2) ekaterina, 20 ч, status=DONE («выполнена») → 20 ч available_hours для коммита
* 3) ivanpetrov, 4 ч, status=IN_PROGRESS → ещё «в работе», часы недоступны
*
* Идея — после этой фазы каждый из двух исполнителей видит на странице
* «Моё время» свои задачи, и кнопка «Создать коммит» в строке проекта
* становится активной (uncommittedHours = available_hours ≥ 1).
*
* Идемпотентно: если задача с таким title в проекте уже есть — пропуск.
*
* Зависит от:
* - phase 07 — компонент имеет мастера `ant` и план (без плана task создание
* валидируется по-другому, и applyExplicitEstimateToTimeEntries не сработает).
* - phases 04, 05 — все три участника зарегистрированы как Contributor с
* активным договором УХД.
*
* НЕ создаёт сами коммиты — это задача сценария blagorost/commits.mjs (UI-документация).
*/
import { Client, Mutations, Queries } from '@coopenomics/sdk'
import { createHash } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:09]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const CHAIRMAN_EMAIL = 'ivanov@example.com'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
// GraphQL-enum IssueStatus принимает значения в верхнем регистре (BACKLOG, TODO,
// IN_PROGRESS, ON_REVIEW, DONE, CANCELED). Внутреннее значение в БД — 'done',
// 'in_progress' и т.д. (lowercase), но в API только UPPERCASE.
type IssueStatusEnum = 'BACKLOG' | 'TODO' | 'IN_PROGRESS' | 'ON_REVIEW' | 'DONE' | 'CANCELED'
interface ITaskSeed {
title: string
description: string
estimate: number
creators: string[]
status: IssueStatusEnum
}
const TASKS: ITaskSeed[] = [
{
title: 'Поднять Vue-каркас кошелька',
description: 'Базовая навигация, маршруты, layout главной страницы — без реальной логики кошельков.',
estimate: 30,
creators: ['ivanpetrov'],
status: 'DONE',
},
{
title: 'Виджет балансов и истории операций',
description: 'Компонент отображения двух кошельков (Генерация / Благорост) и списка последних операций.',
estimate: 20,
creators: ['ekaterina'],
status: 'DONE',
},
{
title: 'Документация модулей',
description: 'README + jsdoc по основным модулям; черновая, без скриншотов.',
estimate: 4,
creators: ['ivanpetrov'],
status: 'IN_PROGRESS',
},
// Задача без estimate — иллюстрирует «время по факту»: cron каждый час
// добавляет 1ч / число активных задач без estimate у contributor'а.
// На странице «Моё время» по этой задаче часы должны тикать сами без
// явной оценки. Используется в инструкции tasks.md (двойной режим time-tracking).
{
title: 'Исследование альтернативных адаптеров кошелька',
description: 'Без чёткой оценки: пробуем подходы, фиксируем что нашли. Время по факту.',
estimate: 0,
creators: ['ekaterina'],
status: 'IN_PROGRESS',
},
]
interface IIssueRow {
issue_hash: string
title: string
status: string
estimate?: number
creators?: string[]
}
export async function phase09(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const info = await blockchain.getInfo()
const chainId = info.chain_id
const chainUrl = `${config.network.protocol}://${config.network.host}${config.network.port}`
const apiUrl = process.env.CONTROLLER_GRAPHQL_URL || 'http://127.0.0.1:2998/v1/graphql'
const wif = config.private_keys[0]
if (!wif) throw new Error('EOSIO_PRV_KEY не задан в env')
const client = Client.create({
api_url: apiUrl,
chain_url: chainUrl,
chain_id: chainId,
wif,
username: CHAIRMAN,
})
log(`логин ${CHAIRMAN_EMAIL} (мастер компонента)...`)
await client.login(CHAIRMAN_EMAIL, wif)
// Список существующих задач компонента — для идемпотентности
let existing: IIssueRow[] = []
try {
const resp = await client.Query(Queries.Capital.GetIssues.query, {
variables: {
filter: { coopname: COOPNAME, project_hash: COMPONENT_HASH },
options: { page: 1, limit: 100 },
} as Queries.Capital.GetIssues.IInput,
}) as Record<string, { items?: IIssueRow[] } | null>
const out = resp[Queries.Capital.GetIssues.name]
existing = out?.items ?? []
} catch (e) {
log(`Query GetIssues упал (это ок при первом запуске): ${(e as Error).message?.slice(0, 100)}`)
}
const existingTitles = new Set(existing.map((x) => x.title))
for (const t of TASKS) {
if (existingTitles.has(t.title)) {
log(`задача «${t.title}» уже есть — пропуск`)
continue
}
log(`создаю задачу «${t.title}» (creator=${t.creators[0]}, ${t.estimate}ч, status=${t.status})`)
await client.Mutation(Mutations.Capital.CreateIssue.mutation, {
variables: {
data: {
coopname: COOPNAME,
title: t.title,
description: t.description,
estimate: t.estimate,
creators: t.creators,
status: t.status,
project_hash: COMPONENT_HASH,
},
} as Mutations.Capital.CreateIssue.IInput,
})
}
log('фаза 09 завершена')
}
@@ -1,214 +0,0 @@
/**
* Фаза 09b — артефакты компонента/проекта (для документации artifacts.md).
*
* Создаёт через controller GraphQL (capitalCreateStory) набор артефактов
* на компоненте «MVP v1» и на проекте «Кошелёк пайщика», по одному
* для каждого из 4 форматов:
* - Markdown (на компоненте) — текстовое требование с заголовками и списком
* - Mermaid (на компоненте) — sequence-диаграмма потока коммитов
* - BPMN (на компоненте) — пустой шаблон (контроллер сам подставит EMPTY_BPMN_STORY_XML)
* - drawio (на компоненте) — пустой шаблон
* - Markdown (на проекте) — общая концепция проекта
*
* Идея — после фазы:
* - вкладка «Артефакты» компонента содержит 4 артефакта разных форматов;
* - вкладка «Артефакты» проекта содержит 1 общую концепцию;
* - каждый артефакт можно открыть → видно конкретный редактор формата.
*
* Идемпотентно: артефакты создаются с детерминированными `story_hash`,
* перед созданием проверяется наличие через GetStories.
*
* Зависит от:
* - phase 06 — проект «Кошелёк пайщика» и компонент «MVP v1» уже созданы.
* - phase 07 — у компонента есть мастер `ant`; can_create_requirement
* для мастера = true.
*/
import { Client, Mutations, Queries } from '@coopenomics/sdk'
import { createHash } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:09b]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const CHAIRMAN_EMAIL = 'ivanov@example.com'
const PROJECT_HASH = createHash('sha256').update('blago:project:48').digest('hex')
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
type ContentFormat = 'MARKDOWN' | 'MERMAID' | 'BPMN' | 'DRAWIO'
interface IArtifactSeed {
/** Стабильный hash для идемпотентности — производится из (project_hash, title) */
hashSeed: string
parentHash: string
parentLabel: string
title: string
content_format: ContentFormat
description: string
}
const MARKDOWN_BODY = `## Требования к экрану «Кошелёк»
### Состав
- Виджет балансов: **Главный кошелёк** и **ЦПП «Благорост»**.
- Список последних 10 операций по любому из кошельков.
- Кнопка «Получить долю» рядом с компонентами, готовыми к выкупу РИД.
### Поведение
- При открытии экран загружает балансы и историю операций.
- Empty state: «Здесь будут отображаться ваши операции».
- Любая операция кликабельна и открывает её детали.
### Не входит в этот компонент
- Подписание Акта приёма-передачи РИД — живёт в отдельном компоненте «Приёмка РИД».
- Импорт/экспорт паевых взносов — в роадмапе, после MVP.
`
const MERMAID_BODY = `sequenceDiagram
participant И as Исполнитель
participant М as Мастер компонента
participant К as Кооператив
И->>М: Зафиксировать коммит (часы, отзыв)
М->>М: Поставить звёздочки удовлетворения
М->>И: Принять или вернуть на доработку
Note over М,К: После приёмки коммит<br/>входит в РИД компонента
М->>К: Внести результат (pushrslt)
`
const PROJECT_CONCEPT_BODY = `## Концепция: Кошелёк пайщика
Цель проекта — дать пайщику единый экран, на котором видны:
- баланс по двум кошелькам (Главный + ЦПП «Благорост»);
- история операций;
- готовые к выкупу доли в ОАП по компонентам.
### Целевая аудитория
- Активные пайщики, участвующие в нескольких компонентах.
- Координаторы — для быстрого обзора движения средств по программе.
### Контекст
Артефакт описывает проект целиком, поэтому виден всем его компонентам:
MVP v1, последующим версиям, доп. компонентам приёмки и отчётности.
`
const ARTIFACTS: IArtifactSeed[] = [
{
hashSeed: 'blago:story:component:markdown:requirements-wallet',
parentHash: COMPONENT_HASH,
parentLabel: 'компонент MVP v1',
title: 'Требования к экрану «Кошелёк»',
content_format: 'MARKDOWN',
description: MARKDOWN_BODY,
},
{
hashSeed: 'blago:story:component:mermaid:commit-flow',
parentHash: COMPONENT_HASH,
parentLabel: 'компонент MVP v1',
title: 'Поток коммитов и приёмки',
content_format: 'MERMAID',
description: MERMAID_BODY,
},
{
hashSeed: 'blago:story:component:bpmn:acceptance-process',
parentHash: COMPONENT_HASH,
parentLabel: 'компонент MVP v1',
title: 'Процесс приёмки РИД (BPMN)',
content_format: 'BPMN',
description: '',
},
{
hashSeed: 'blago:story:component:drawio:architecture',
parentHash: COMPONENT_HASH,
parentLabel: 'компонент MVP v1',
title: 'Архитектурная схема компонентов',
content_format: 'DRAWIO',
description: '',
},
{
hashSeed: 'blago:story:project:markdown:concept',
parentHash: PROJECT_HASH,
parentLabel: 'проект «Кошелёк пайщика»',
title: 'Концепция проекта «Кошелёк пайщика»',
content_format: 'MARKDOWN',
description: PROJECT_CONCEPT_BODY,
},
]
interface IStoryRow {
story_hash: string
title: string
content_format: string
project_hash?: string
}
export async function phase09b(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const info = await blockchain.getInfo()
const chainId = info.chain_id
const chainUrl = `${config.network.protocol}://${config.network.host}${config.network.port}`
const apiUrl = process.env.CONTROLLER_GRAPHQL_URL || 'http://127.0.0.1:2998/v1/graphql'
const wif = config.private_keys[0]
if (!wif) throw new Error('EOSIO_PRV_KEY не задан в env')
const client = Client.create({
api_url: apiUrl,
chain_url: chainUrl,
chain_id: chainId,
wif,
username: CHAIRMAN,
})
log(`логин ${CHAIRMAN_EMAIL} (мастер компонента)...`)
await client.login(CHAIRMAN_EMAIL, wif)
// Подгружаем все существующие артефакты компонента и проекта — для идемпотентности.
const existingHashes = new Set<string>()
for (const parentHash of [COMPONENT_HASH, PROJECT_HASH]) {
try {
const resp = (await client.Query(Queries.Capital.GetStories.query, {
variables: {
filter: { coopname: COOPNAME, project_hash: parentHash },
options: { page: 1, limit: 100 },
} as Queries.Capital.GetStories.IInput,
})) as Record<string, { items?: IStoryRow[] } | null>
const out = resp[Queries.Capital.GetStories.name]
for (const it of out?.items ?? []) existingHashes.add(it.story_hash.toLowerCase())
} catch (e) {
log(`Query GetStories(${parentHash.slice(0, 8)}) упал (это ок при первом запуске): ${(e as Error).message?.slice(0, 100)}`)
}
}
for (const a of ARTIFACTS) {
const story_hash = createHash('sha256').update(a.hashSeed).digest('hex')
if (existingHashes.has(story_hash)) {
log(`артефакт «${a.title}» уже есть (${story_hash.slice(0, 8)}…) — пропуск`)
continue
}
log(`создаю артефакт «${a.title}» (${a.content_format}${a.parentLabel})`)
await client.Mutation(Mutations.Capital.CreateStory.mutation, {
variables: {
data: {
coopname: COOPNAME,
story_hash,
title: a.title,
description: a.description,
content_format: a.content_format,
project_hash: a.parentHash,
},
} as Mutations.Capital.CreateStory.IInput,
})
}
log('фаза 09b завершена')
}
@@ -1,161 +0,0 @@
/**
* Фаза 10 — коммиты + приёмка мастером + перевод компонента в голосование.
*
* Сценарий:
* 1) ivanpetrov коммитит 8 ч на компонент «MVP v1» (capital::createcmmt).
* 2) ekaterina коммитит 6 ч на тот же компонент.
* 3) ant (мастер) принимает оба коммита (capital::approvecmmt) — часы
* становятся подтверждёнными, попадают в Кошелёк Генерации исполнителя.
* 4) ant (мастер) переводит компонент в статус VOTING (capital::startvoting).
*
* После фазы:
* - страница «Голосование по компоненту» доступна;
* - participants компонента (ivanpetrov, ekaterina, ant — те у кого has_vote=true)
* могут проголосовать.
*
* Фаза НЕ голосует — это работа сценария blagorost/voting.mjs (UI-документация).
*
* Идемпотентно: смотрит project.status и project.counts.total_commits;
* если уже VOTING — фаза no-op; если ACTIVE и часть коммитов уже принята —
* пропускает по contributor.contributed_hours.
*/
import { CapitalContract } from 'cooptypes'
import { createHash } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:10]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
interface IProjectRow {
status?: string
master?: string
counts?: { total_commits?: number }
}
interface IContributorRow {
username?: string
contributed_hours?: number | string
}
async function getProject(blockchain: Blockchain): Promise<IProjectRow | undefined> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'projects',
1,
COMPONENT_HASH,
COMPONENT_HASH,
3,
'sha256',
)
return rows[0]
}
async function getContributedHours(blockchain: Blockchain, username: string): Promise<number> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'segments',
100,
COMPONENT_HASH,
COMPONENT_HASH,
2,
'sha256',
) as Array<{ username?: string, fact?: { creator_hours?: number | string } }>
const seg = rows.find((r) => r.username === username)
if (!seg) return 0
const v = seg.fact?.creator_hours ?? 0
return typeof v === 'string' ? parseInt(v, 10) || 0 : Number(v) || 0
}
function commitHashFor(username: string): string {
return createHash('sha256').update(`commit:${username}:mvp-v1`).digest('hex')
}
async function commitAndApprove(
blockchain: Blockchain,
username: string,
hours: number,
): Promise<void> {
const already = await getContributedHours(blockchain, username)
if (already >= hours) {
log(`${username}: уже зачтено ${already}ч (>= ${hours}ч) — пропуск`)
return
}
const commitHash = commitHashFor(username)
log(`capital::createcmmt ${username}${hours}ч (commit_hash=${commitHash.slice(0, 12)}...)`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CreateCommit.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
project_hash: COMPONENT_HASH,
commit_hash: commitHash,
creator_hours: hours,
description: `Seed-коммит ${username} (${hours}ч) для документации`,
meta: JSON.stringify({ seed: true, hours }),
} as CapitalContract.Actions.CreateCommit.ICommit,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log(`capital::approvecmmt ${CHAIRMAN} → коммит ${username}`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CommitApprove.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
master: CHAIRMAN,
commit_hash: commitHash,
} as CapitalContract.Actions.CommitApprove.ICommitApprove,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
export async function phase10(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const project = await getProject(blockchain)
if (!project) throw new Error(`компонент MVP v1 (${COMPONENT_HASH.slice(0, 12)}...) не найден`)
log(`статус компонента: ${project.status}, total_commits=${project.counts?.total_commits ?? 0}`)
if (project.status === 'voting' || project.status === 'result' || project.status === 'finalized') {
log(`компонент уже в статусе ${project.status} — фаза no-op`)
return
}
if (project.status !== 'active') {
throw new Error(`компонент в статусе ${project.status}; ожидался active (нужен phase 08 — startproject + openproject)`)
}
// 1+2+3: коммиты от исполнителей + приёмка мастером.
// Часы выбраны достаточными, чтобы пул кошелька «ЦПП Генератор — принятый
// коммит» (≈ часы × hour_cost) покрывал последующий выкуп РИД (фаза 13).
await commitAndApprove(blockchain, 'ivanpetrov', 30)
await commitAndApprove(blockchain, 'ekaterina', 20)
// 4: перевод в голосование (capital::startvoting).
log(`capital::startvoting → компонент MVP v1`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.StartVoting.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
} as CapitalContract.Actions.StartVoting.IStartVoting,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log('фаза 10 завершена — компонент переведён в голосование')
}
@@ -1,100 +0,0 @@
/**
* Фаза 10a — pending-коммит для документации мастера.
*
* Создаёт ОДИН коммит ivanpetrov на компоненте «MVP v1» через
* `capital::createcmmt` БЕЗ приёмки мастером. Сегмент остаётся в pending,
* у мастера на странице «Коммиты» появляется неутверждённый коммит — это
* целевое состояние для скриншотов flow приёмки.
*
* Идемпотентно: если у ivanpetrov уже есть pending-коммит (по нашему
* детерминированному `commit_hash`), фаза no-op.
*
* Зависит от 07/07b/09: компонент со статусом active, у пайщика есть допуск
* и хотя бы одна задача DONE с estimate=8 ч.
*/
import { CapitalContract } from 'cooptypes'
import { createHash } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:10a]', ...a)
const COOPNAME = 'voskhod'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
interface IProjectRow {
status?: string
master?: string
}
async function getProject(blockchain: Blockchain): Promise<IProjectRow | undefined> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'projects',
1,
COMPONENT_HASH,
COMPONENT_HASH,
3,
'sha256',
)
return rows[0]
}
interface ICommitRow {
commit_hash?: string
status?: string
}
async function findPendingCommit(blockchain: Blockchain, hash: string): Promise<ICommitRow | undefined> {
// Таблица commits — scope = coopname (см. commit_index в commits.hpp).
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'commits',
100,
) as ICommitRow[]
return rows.find((r) => (r.commit_hash ?? '').toUpperCase() === hash.toUpperCase())
}
export async function phase10a(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const project = await getProject(blockchain)
if (!project) throw new Error(`компонент MVP v1 не найден`)
if (project.status !== 'active') {
log(`компонент в статусе ${project.status} — фаза no-op (нужен active)`)
return
}
const username = 'ivanpetrov'
const hours = 8
const commitHash = createHash('sha256').update(`commit:pending:${username}:mvp-v1`).digest('hex')
const existing = await findPendingCommit(blockchain, commitHash)
if (existing) {
log(`pending-коммит ${username} (${commitHash.slice(0, 12)}...) уже есть, status=${existing.status} — пропуск`)
return
}
log(`capital::createcmmt ${username}${hours}ч (commit_hash=${commitHash.slice(0, 12)}...) БЕЗ approve`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CreateCommit.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
project_hash: COMPONENT_HASH,
commit_hash: commitHash,
creator_hours: hours,
description: `Pending seed-коммит ${username} (${hours} ч) для скриншотов мастера`,
meta: JSON.stringify({ seed: true, hours, pending: true }),
} as CapitalContract.Actions.CreateCommit.ICommit,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log('фаза 10a завершена')
}
@@ -1,207 +0,0 @@
/**
* Фаза 11 — голосование всех участников и закрытие голосования.
*
* Сценарий:
* 1) Читаем проект MVP v1: статус должен быть 'voting'.
* 2) Читаем сегменты компонента → список участников с has_vote=true.
* 3) Читаем таблицу `votes` → кто уже проголосовал (для идемпотентности).
* 4) Для каждого ещё-не-голосовавшего участника:
* capital::submitvote с равномерным распределением
* project.voting.amounts.active_voting_amount между остальными
* участниками (себя исключаем). Остаток от деления отдаём
* последнему, чтобы сумма голосов точно сошлась.
* 5) Когда проголосует последний — контракт сам переводит компонент
* в статус 'result'. Если же deadline истёк раньше, всё равно
* проверяем: если статус остался 'voting' — финализируем
* capital::cmpltvoting (на случай ручного запуска фазы после deadline).
*
* Идемпотентно:
* - status === 'result' | 'finalized' → no-op;
* - status === 'voting' → пропускаем тех, чьи голоса уже в таблице votes;
* - status === 'active' → ошибка (нужно сначала phase 10).
*/
import { CapitalContract } from 'cooptypes'
import { createHash } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:11]', ...a)
const COOPNAME = 'voskhod'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
interface IVotingAmounts {
active_voting_amount: string
total_voting_pool: string
}
interface IProjectRow {
status?: string
voting?: {
total_voters?: number
votes_received?: number
amounts?: IVotingAmounts
}
}
interface ISegmentRow {
username: string
has_vote: boolean | number
}
interface IVoteRow {
project_hash: string
voter: string
recipient: string
amount: string
}
async function getProject(blockchain: Blockchain): Promise<IProjectRow | undefined> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'projects',
1,
COMPONENT_HASH,
COMPONENT_HASH,
3,
'sha256',
)
return rows[0]
}
async function getVoters(blockchain: Blockchain): Promise<string[]> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'segments',
100,
COMPONENT_HASH,
COMPONENT_HASH,
2,
'sha256',
) as ISegmentRow[]
return rows.filter((r) => Boolean(r.has_vote)).map((r) => r.username)
}
async function getAlreadyVoted(blockchain: Blockchain): Promise<Set<string>> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'votes',
1000,
COMPONENT_HASH,
COMPONENT_HASH,
2,
'sha256',
) as IVoteRow[]
const voted = new Set<string>()
for (const r of rows) {
if (r.project_hash === COMPONENT_HASH) voted.add(r.voter)
}
return voted
}
/**
* Точное распределение `votingAmount` между `recipients` с шагом 0.0001.
* Целочисленная арифметика по 1/10000 единицы избегает плавающей-точечной
* ошибки. Остаток отдаём последнему получателю — сумма распределённых
* голосов в точности равна голосующей сумме.
*/
function distributeEvenly(votingAmount: string, recipients: string[]) {
const [num, currency] = votingAmount.split(' ')
const totalUnits = Math.round(parseFloat(num) * 10000)
const n = recipients.length
if (n === 0) return [] as Array<{ recipient: string, amount: string }>
const base = Math.floor(totalUnits / n)
const remainder = totalUnits - base * n
return recipients.map((recipient, i) => {
const units = i === n - 1 ? base + remainder : base
const amount = `${(units / 10000).toFixed(4)} ${currency}`
return { recipient, amount }
})
}
async function castVote(
blockchain: Blockchain,
voter: string,
votes: Array<{ recipient: string, amount: string }>,
) {
log(`capital::submitvote от ${voter} → распределение по ${votes.length} участникам`)
for (const v of votes) log(` ${voter}${v.recipient}: ${v.amount}`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.SubmitVote.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
voter,
project_hash: COMPONENT_HASH,
votes,
} as CapitalContract.Actions.SubmitVote.ISubmitVote,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function completeVotingIfStuck(blockchain: Blockchain) {
const project = await getProject(blockchain)
if (project?.status !== 'voting') return
log(`статус всё ещё 'voting' — вызываем capital::cmpltvoting`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CompleteVoting.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
} as CapitalContract.Actions.CompleteVoting.ICompleteVoting,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
export async function phase11(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const project = await getProject(blockchain)
if (!project) throw new Error(`компонент MVP v1 (${COMPONENT_HASH.slice(0, 12)}...) не найден`)
log(`статус компонента: ${project.status}, votes_received=${project.voting?.votes_received ?? '?'}/${project.voting?.total_voters ?? '?'}`)
if (project.status === 'result' || project.status === 'finalized') {
log(`компонент уже в статусе ${project.status} — фаза no-op`)
return
}
if (project.status !== 'voting') {
throw new Error(`компонент в статусе ${project.status}; ожидался voting (нужен phase 10 — startvoting)`)
}
const votingAmount = project.voting?.amounts?.active_voting_amount
if (!votingAmount) throw new Error('voting.amounts.active_voting_amount не найден в проекте')
log(`голосующая сумма (active_voting_amount): ${votingAmount}`)
const voters = await getVoters(blockchain)
if (voters.length < 2) throw new Error(`должно быть ≥2 участников с has_vote=true; найдено: ${voters.length}`)
log(`участники с правом голоса: [${voters.join(', ')}]`)
const alreadyVoted = await getAlreadyVoted(blockchain)
if (alreadyVoted.size > 0) log(`уже проголосовали: [${[...alreadyVoted].join(', ')}]`)
for (const voter of voters) {
if (alreadyVoted.has(voter)) {
log(`${voter}: уже голосовал — пропуск`)
continue
}
const recipients = voters.filter((v) => v !== voter)
const distribution = distributeEvenly(votingAmount, recipients)
await castVote(blockchain, voter, distribution)
}
// Последний голос обычно сам закрывает голосование. На всякий случай
// (например, фазу запускают после deadline без полного кворума) — финализируем.
await completeVotingIfStuck(blockchain)
const after = await getProject(blockchain)
log(`фаза 11 завершена — итоговый статус: ${after?.status}`)
}
@@ -1,142 +0,0 @@
/**
* Фаза 12 — пересчёт сегментов и расчёт голосов после голосования.
*
* После phase 11 компонент в статусе `result`, но сегменты застряли в
* `generation` с `is_votes_calculated=0` и `voting_bonus=0`. На странице
* «Результаты компонента» это та точка, где пайщики видят кнопки
* «Пересчитать результат» и «Рассчитать голоса». В будущем шаг будет
* автоматическим — пока вызываем явно.
*
* Сценарий:
* 1) Для каждого участника компонента — `capital::rfrshsegment`
* (пересчёт CRPS-полей; статус сегмента: generation → ready).
* 2) Для каждого участника с has_vote=true — `capital::calcvotes`
* (расчёт voting_bonus по методу Водянова; is_votes_calculated=true).
*
* После фазы:
* - все сегменты в статусе `ready`;
* - voting_bonus заполнен у тех, кто голосовал;
* - на странице «Результаты» становится активной кнопка «Внести результат».
*
* Идемпотентно: проверяем status сегмента и is_votes_calculated.
*/
import { CapitalContract } from 'cooptypes'
import { createHash } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:12]', ...a)
const COOPNAME = 'voskhod'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
interface ISegmentRow {
username: string
status: string
has_vote: boolean | number
is_votes_calculated: boolean | number
}
interface IProjectRow {
status?: string
}
async function getProject(blockchain: Blockchain): Promise<IProjectRow | undefined> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'projects',
1,
COMPONENT_HASH,
COMPONENT_HASH,
3,
'sha256',
)
return rows[0]
}
async function getSegments(blockchain: Blockchain): Promise<ISegmentRow[]> {
return await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'segments',
100,
COMPONENT_HASH,
COMPONENT_HASH,
2,
'sha256',
) as ISegmentRow[]
}
async function refreshSegment(blockchain: Blockchain, username: string) {
log(`capital::rfrshsegment ${username}`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.RefreshSegment.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
username,
} as CapitalContract.Actions.RefreshSegment.IRefreshSegment,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function calcVotes(blockchain: Blockchain, username: string) {
log(`capital::calcvotes ${username}`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.CalculateVotes.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
username,
} as CapitalContract.Actions.CalculateVotes.IFinalVoting,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
export async function phase12(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const project = await getProject(blockchain)
if (!project) throw new Error(`компонент MVP v1 не найден`)
log(`статус компонента: ${project.status}`)
if (project.status !== 'result' && project.status !== 'finalized') {
throw new Error(`компонент в статусе ${project.status}; ожидался result (нужны фазы 10+11)`)
}
const segments = await getSegments(blockchain)
log(`сегменты: ${segments.map((s) => `${s.username}(${s.status})`).join(', ')}`)
// 1) refreshSegment — для каждого, кто ещё в generation.
for (const s of segments) {
if (s.status === 'generation') {
await refreshSegment(blockchain, s.username)
} else {
log(`${s.username}: статус ${s.status} — rfrshsegment пропускаем`)
}
}
// 2) calcvotes — для каждого с has_vote=true и is_votes_calculated=false.
// Перечитываем сегменты после refresh.
const refreshed = await getSegments(blockchain)
for (const s of refreshed) {
if (Boolean(s.has_vote) && !Boolean(s.is_votes_calculated)) {
await calcVotes(blockchain, s.username)
} else {
log(`${s.username}: has_vote=${s.has_vote} votes_calculated=${s.is_votes_calculated} — calcvotes пропускаем`)
}
}
const after = await getSegments(blockchain)
for (const s of after) {
log(`итог [${s.username}] status=${s.status} votes_calculated=${s.is_votes_calculated}`)
}
log('фаза 12 завершена')
}
@@ -1,282 +0,0 @@
/**
* Фаза 13 — внесение паевого взноса по результатам интеллектуальной
* деятельности (РИД).
*
* Контекст: после phase 12 сегменты в статусе `ready`, voting_bonus
* рассчитан, intellectual_cost известен. Дальше каждый участник с
* ненулевой интеллектуальной стоимостью оформляет паевой взнос РИД:
*
* 1) `capital::pushrslt` — заявление на внесение результата;
* создаёт запись в таблице `results` (статус `created`),
* сегмент: ready → statement.
* 2) `soviet::confirmapprv` (председатель) — одобрение результата
* (запускает процедуру совета).
* 3) `soviet::voteFor + authorize + exec` (председатель) —
* обработка решения совета; результат: authorized,
* сегмент: statement → approved → authorized.
* 4) `capital::signact1` — пайщик подписывает Акт №1
* (приёма-передачи РИД); сегмент: authorized → act1.
* 5) `capital::signact2` — председатель подписывает Акт №2;
* сегмент: act1 → contributed.
*
* После фазы:
* - все участники с intellectual_cost>0 в статусе `contributed`;
* - сегмент готов к конвертации (фаза 14).
*
* Идемпотентно: пропускаем участников с status === 'contributed'.
*
* Подписи в seed-фазе — фейковые (как в тестах капитала). Реальный
* UI-сценарий генерирует документы через SDK и подписывает ключом
* пайщика; для документации этого уровня детализации не требуется.
*/
import { CapitalContract, SovietContract } from 'cooptypes'
import { createHash, randomBytes } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
import { setDocumentSignatures } from '../../../utils/setDocumentSignatures'
import { fakeDocument } from '../../../tests/shared/fakeDocument'
import { fakeVote } from '../../../tests/shared/fakeVote'
const log = (...a: unknown[]) => console.error('[seed-capital:13]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
interface ISegmentRow {
username: string
status: string
is_author: boolean | number
is_creator: boolean | number
intellectual_cost: string
}
function randomSha256(): string {
return createHash('sha256').update(randomBytes(32)).digest('hex')
}
async function getSegments(blockchain: Blockchain): Promise<ISegmentRow[]> {
return await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'segments',
100,
COMPONENT_HASH,
COMPONENT_HASH,
2,
'sha256',
) as ISegmentRow[]
}
async function pushResult(
blockchain: Blockchain,
username: string,
contributionAmount: string,
resultHash: string,
statement: ReturnType<typeof setDocumentSignatures>,
) {
log(`capital::pushrslt ${username} → contribution=${contributionAmount} (result_hash=${resultHash.slice(0, 12)}...)`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.PushResult.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
project_hash: COMPONENT_HASH,
result_hash: resultHash,
contribution_amount: contributionAmount,
debt_amount: '0.0000 RUB',
statement,
debt_hashes: [],
} as CapitalContract.Actions.PushResult.IPushResult,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function sovietApprove(blockchain: Blockchain, approvalHash: string) {
log(`soviet::confirmapprv ${CHAIRMAN}${approvalHash.slice(0, 12)}...`)
const approvedDoc = JSON.parse(JSON.stringify(fakeDocument))
approvedDoc.signatures[0].signer = CHAIRMAN
await blockchain.api.transact({
actions: [{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Approves.ConfirmApprove.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username: CHAIRMAN,
approval_hash: approvalHash,
approved_document: approvedDoc,
} as SovietContract.Actions.Approves.ConfirmApprove.IConfirmApprove,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
interface IBoardMember {
username: string
is_voting: number | boolean
}
interface IBoardRow {
id: number
members?: IBoardMember[]
}
async function getVotingBoardMembers(blockchain: Blockchain): Promise<string[]> {
const boards = await blockchain.getTableRows(
SovietContract.contractName.production,
COOPNAME,
'boards',
10,
) as IBoardRow[]
const board = boards[0]
if (!board?.members?.length) return [CHAIRMAN]
return board.members
.filter((m) => Boolean(m.is_voting))
.map((m) => m.username)
}
async function processLastDecision(blockchain: Blockchain) {
const decisions = await blockchain.getTableRows(
SovietContract.contractName.production,
COOPNAME,
SovietContract.Tables.Decisions.tableName,
100,
) as Array<{ id: number }>
const last = decisions[decisions.length - 1]
if (!last) throw new Error('soviet::decisions пуст — нечего голосовать')
// boot:extra собирает совет из 5 членов (ant chairman + petr/anna/mikhail/olga).
// soviet::authorize требует консенсус — голосовать должны все voting-члены.
// boot (1 chairman) тоже работает — там список = [ant], одного голоса хватает.
const voters = await getVotingBoardMembers(blockchain)
log(`soviet decision id=${last.id} — голосуют ${voters.length} члена совета (${voters.join(', ')})`)
const voteActions = voters.map((voter) => ({
account: SovietContract.contractName.production,
name: SovietContract.Actions.Decisions.VoteFor.actionName,
authorization: [{ actor: voter, permission: 'active' }],
data: {
...fakeVote,
coopname: COOPNAME,
username: voter,
decision_id: String(last.id),
} as unknown as SovietContract.Actions.Decisions.VoteFor.IVoteForDecision,
}))
const authData: SovietContract.Actions.Decisions.Authorize.IAuthorize = {
coopname: COOPNAME,
chairman: CHAIRMAN,
decision_id: last.id,
document: fakeDocument,
}
const execData: SovietContract.Actions.Decisions.Exec.IExec = {
executer: CHAIRMAN,
coopname: COOPNAME,
decision_id: last.id,
}
await blockchain.api.transact({
actions: [
...voteActions,
{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Decisions.Authorize.actionName,
authorization: [{ actor: CHAIRMAN, permission: 'active' }],
data: authData,
},
{
account: SovietContract.contractName.production,
name: SovietContract.Actions.Decisions.Exec.actionName,
authorization: [{ actor: CHAIRMAN, permission: 'active' }],
data: execData,
},
],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function signAct1(
blockchain: Blockchain,
username: string,
resultHash: string,
act: ReturnType<typeof setDocumentSignatures>,
) {
log(`capital::signact1 ${username}${resultHash.slice(0, 12)}...`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.SignAct1.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
result_hash: resultHash,
act,
} as CapitalContract.Actions.SignAct1.ISignAct1,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function signAct2(
blockchain: Blockchain,
resultHash: string,
act: ReturnType<typeof setDocumentSignatures>,
) {
log(`capital::signact2 ${CHAIRMAN}${resultHash.slice(0, 12)}...`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.SignAct2.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
chairman: CHAIRMAN,
result_hash: resultHash,
act,
} as CapitalContract.Actions.SignAct2.ISignAct2,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function contributeForUser(blockchain: Blockchain, username: string, amount: string) {
const resultHash = randomSha256()
const statement = setDocumentSignatures([username])
await pushResult(blockchain, username, amount, resultHash, statement)
await sovietApprove(blockchain, resultHash)
await processLastDecision(blockchain)
await signAct1(blockchain, username, resultHash, statement)
const act2 = setDocumentSignatures([username, CHAIRMAN])
await signAct2(blockchain, resultHash, act2)
}
export async function phase13(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const segments = await getSegments(blockchain)
log(`сегменты: ${segments.map((s) => `${s.username}(${s.status},ic=${s.intellectual_cost})`).join(', ')}`)
for (const s of segments) {
if (s.status === 'contributed' || s.status === 'finalized') {
log(`${s.username}: уже ${s.status} — пропуск`)
continue
}
if (s.status !== 'ready') {
throw new Error(`${s.username}: неожиданный статус ${s.status}; ожидался ready (нужна phase 12)`)
}
const amount = parseFloat(s.intellectual_cost.split(' ')[0])
if (amount <= 0) {
log(`${s.username}: intellectual_cost=${s.intellectual_cost} — паевой взнос РИД пропускаем`)
continue
}
log(`--- ${s.username}: вносим РИД на ${s.intellectual_cost} ---`)
await contributeForUser(blockchain, s.username, s.intellectual_cost)
}
const after = await getSegments(blockchain)
for (const s of after) {
log(`итог [${s.username}] status=${s.status}`)
}
log('фаза 13 завершена')
}
@@ -1,196 +0,0 @@
/**
* Фаза 14 — конвертация сегментов и финализация проекта.
*
* После phase 13 каждый участник в статусе `contributed`. На странице
* «Результаты» у него активна кнопка «Получить долю» — она открывает
* диалог с рубильником между Главным кошельком и программой Благорост:
*
* wallet_amount ─── переводится в Главный кошелёк (свободные средства);
* ограничен available_for_wallet сегмента.
* capital_amount ─── переводится в кошелёк программы Благорост
* (заблокированные паевые взносы; реинвестиция).
*
* Сумма wallet_amount + capital_amount = available_for_program (полный
* размер доли участника). Контракт сам гарантирует, что wallet_amount
* не превышает available_for_wallet.
*
* Чтобы документация показывала разные положения рубильника, для трёх
* участников выбраны разные стратегии:
*
* ant → 100 % в Главный кошелёк (рубильник вправо)
* ivanpetrov → 50 / 50 (рубильник посередине)
* ekaterina → 100 % в Благорост (рубильник влево)
*
* Если у кого-то available_for_wallet=0, фактический wallet_amount
* усечётся до 0 — это ограничение контракта, а не фазы.
*
* После всех конвертаций — `capital::finalizeproj` (project: result →
* finalized).
*
* Идемпотентно: сегмент после convertsegm удаляется из таблицы;
* пропускаем участников, у которых сегмента нет.
*/
import { CapitalContract } from 'cooptypes'
import { createHash, randomBytes } from 'node:crypto'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
import { fakeDocument } from '../../../tests/shared/fakeDocument'
const log = (...a: unknown[]) => console.error('[seed-capital:14]', ...a)
const COOPNAME = 'voskhod'
const COMPONENT_HASH = createHash('sha256').update('blago:project:49').digest('hex')
// Доля available_for_wallet, которая уйдёт в Главный кошелёк.
// Остаток (от available_for_program) — в Благорост.
const WALLET_RATIO_BY_USERNAME: Record<string, number> = {
ant: 1.0, // рубильник вправо: всё в Главный кошелёк
ivanpetrov: 0.5, // 50 / 50
ekaterina: 0.0, // рубильник влево: всё в Благорост
}
interface ISegmentRow {
username: string
status: string
available_for_wallet: string
available_for_program: string
}
interface IProjectRow {
status?: string
}
function randomSha256(): string {
return createHash('sha256').update(randomBytes(32)).digest('hex')
}
function parseRub(asset: string): number {
return parseFloat(asset.split(' ')[0])
}
function fmtRub(amountRub: number): string {
// Округляем вниз до 4 знаков, чтобы не превысить available.
const units = Math.floor(amountRub * 10000)
return `${(units / 10000).toFixed(4)} RUB`
}
async function getProject(blockchain: Blockchain): Promise<IProjectRow | undefined> {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'projects',
1,
COMPONENT_HASH,
COMPONENT_HASH,
3,
'sha256',
)
return rows[0]
}
async function getSegments(blockchain: Blockchain): Promise<ISegmentRow[]> {
return await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME,
'segments',
100,
COMPONENT_HASH,
COMPONENT_HASH,
2,
'sha256',
) as ISegmentRow[]
}
async function convertSegment(
blockchain: Blockchain,
username: string,
walletAmount: string,
capitalAmount: string,
) {
const convertHash = randomSha256()
log(`capital::convertsegm ${username} → wallet=${walletAmount}, capital=${capitalAmount}`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.ConvertSegment.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
username,
project_hash: COMPONENT_HASH,
convert_hash: convertHash,
wallet_amount: walletAmount,
capital_amount: capitalAmount,
convert_statement: fakeDocument,
} as CapitalContract.Actions.ConvertSegment.IConvertSegment,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function finalizeProject(blockchain: Blockchain) {
log(`capital::finalizeproj`)
await blockchain.api.transact({
actions: [{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.FinalizeProject.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
} as CapitalContract.Actions.FinalizeProject.IFinalizeProject,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
export async function phase14(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const project = await getProject(blockchain)
if (!project) throw new Error(`компонент MVP v1 не найден`)
log(`статус компонента: ${project.status}`)
if (project.status === 'finalized') {
log(`компонент уже finalized — фаза no-op`)
return
}
if (project.status !== 'result') {
throw new Error(`компонент в статусе ${project.status}; ожидался result`)
}
const segments = await getSegments(blockchain)
log(`сегменты к конвертации: ${segments.map((s) => `${s.username}(${s.status})`).join(', ')}`)
for (const s of segments) {
if (s.status !== 'contributed') {
log(`${s.username}: статус ${s.status} (ожидалось contributed) — пропуск`)
continue
}
const ratio = WALLET_RATIO_BY_USERNAME[s.username] ?? 0.0
const availWallet = parseRub(s.available_for_wallet)
const availProgram = parseRub(s.available_for_program)
// Сколько хочется в Главный кошелёк, ограничиваем фактическим лимитом.
const desiredWallet = availWallet * ratio
const walletAmount = Math.min(desiredWallet, availWallet)
const capitalAmount = availProgram - walletAmount
if (capitalAmount < 0) {
throw new Error(`${s.username}: capital_amount<0 (avail_program=${availProgram}, wallet=${walletAmount})`)
}
log(`${s.username}: avail_wallet=${availWallet}, avail_program=${availProgram}, ratio=${ratio}`)
await convertSegment(blockchain, s.username, fmtRub(walletAmount), fmtRub(capitalAmount))
}
// После всех convertsegm все сегменты компонента удалены из таблицы.
// Проект можно перевести в finalized.
const remaining = await getSegments(blockchain)
if (remaining.length > 0) {
log(`остались сегменты: ${remaining.map((s) => s.username).join(', ')} — finalizeproj пропускаем`)
} else {
await finalizeProject(blockchain)
}
const after = await getProject(blockchain)
log(`фаза 14 завершена — итоговый статус: ${after?.status}`)
}
@@ -1,148 +0,0 @@
/**
* Фаза 15 — компонент в статусе Active без коммитов.
*
* Берёт компонент «Минимальный продукт» (id=15) под проектом «Приложение
* Стол Заказов» (id=3) — он создан фазой 03 как pending. Превращает в
* стартовое состояние «работа открыта, коммитов ещё нет»:
* - clearance ant/ivanpetrov/ekaterina к проекту-3 и к компоненту-15;
* - setmaster=ant на компонент-15;
* - setplan на компонент-15 (160 ч × 1500 RUB);
* - startproject на компонент-15 → status=Active.
*
* Не создаёт задачи, коммиты и не открывает голосование. UI должен показать
* проект «Стол заказов» с компонентом «Минимальный продукт» в статусе Active
* с назначенным мастером, готовый принимать коммиты.
*/
import { Client, Mutations } from '@coopenomics/sdk'
import { CapitalContract, SovietContract } from 'cooptypes'
import Blockchain from '../../../blockchain'
import config from '../../../configs'
const log = (...a: unknown[]) => console.error('[seed-capital:15]', ...a)
const COOPNAME = 'voskhod'
const CHAIRMAN = 'ant'
const CHAIRMAN_EMAIL = 'ivanov@example.com'
// id=3 «Приложение Стол Заказов» (parent для component-15)
const PROJECT_HASH = '79fc8a7e99f17449d13a399421f5a9edbad31187eae36462307a9744aa45d917'
// id=15 «Минимальный продукт»
const COMPONENT_HASH = '759ac130a21c9586e3b9028ecbbc42805cc0593fe51042c4ad2113cbb1a938e6'
const PARTICIPANTS = ['ant', 'ivanpetrov', 'ekaterina'] as const
const FAKE_HASH = '157192b276da23cc84ab078fc8755c051c5f0430bf4802e55718221e6b76c777'
function fakeDoc(signer: string) {
return {
version: '1.0.0',
hash: FAKE_HASH,
doc_hash: FAKE_HASH,
meta_hash: FAKE_HASH,
meta: '{}',
signatures: [{
id: 1,
signed_hash: FAKE_HASH,
signer,
public_key: 'EOS5JhMfxbsNebajHcTEK8yC9uNN9Dit9hEmzE8ri8yMhhzxrLg3J',
signature: 'SIG_K1_KmKWPBC8dZGGDGhbKEoZEzPr3h5crRrR2uLdGRF5DJbeibY1MY1bZ9sPwHsgmPfiGFv9psfoCVsXFh9TekcLuvaeuxRKA8',
signed_at: '2025-05-14T12:22:26',
meta: '{}',
}],
}
}
import { createHash } from 'node:crypto'
function makeAppendixHash(username: string, projectHash: string, tag: string): string {
return createHash('sha256').update(`appendix:15:${username}:${projectHash}:${tag}`).digest('hex')
}
interface IContributorRow {
username?: string
appendixes?: string[]
}
async function signClearance(blockchain: Blockchain, username: string, projectHash: string, tag: string) {
const rows = await blockchain.getTableRows(
CapitalContract.contractName.production,
COOPNAME, 'contributors', 1, username, username, 2, 'i64',
) as IContributorRow[]
if (rows[0]?.appendixes?.map(h => h.toLowerCase()).includes(projectHash.toLowerCase())) {
log(`${username}${projectHash.slice(0, 10)}... уже есть допуск, пропуск`)
return
}
const appendixHash = makeAppendixHash(username, projectHash, tag)
log(`getclearance ${username}${projectHash.slice(0, 10)}...`)
await blockchain.api.transact({
actions: [{
account: 'capital',
name: 'getclearance',
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: { coopname: COOPNAME, username, project_hash: projectHash, appendix_hash: appendixHash, document: fakeDoc(username) },
}],
}, { blocksBehind: 3, expireSeconds: 30 })
// Sleep ≥ block_time чтобы getclearance и confirmapprv ушли в разные блоки —
// иначе SHIP схлопнет дельту appendixes (insert+erase в одном блоке).
await new Promise(r => setTimeout(r, 700))
log(`confirmapprv ${username} (${appendixHash.slice(0, 10)}...)`)
await blockchain.api.transact({
actions: [{
account: 'soviet',
name: SovietContract.Actions.Approves.ConfirmApprove.actionName,
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: { coopname: COOPNAME, username: CHAIRMAN, approval_hash: appendixHash, approved_document: fakeDoc(CHAIRMAN) },
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
export async function phase15(): Promise<void> {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
// 1. Clearance всем трём — к проекту и к компоненту.
for (const u of PARTICIPANTS) {
for (const [hash, tag] of [[PROJECT_HASH, 'project-3'], [COMPONENT_HASH, 'component-15']] as const) {
await signClearance(blockchain, u, hash, tag)
}
}
// 2. setmaster + setplan + startproject через GraphQL controller (мастер ant).
const info = await blockchain.getInfo()
const apiUrl = process.env.CONTROLLER_GRAPHQL_URL || 'http://127.0.0.1:2998/v1/graphql'
const chainUrl = `${config.network.protocol}://${config.network.host}${config.network.port}`
const wif = config.private_keys[0]
if (!wif) throw new Error('EOSIO_PRV_KEY не задан')
const client = Client.create({ api_url: apiUrl, chain_url: chainUrl, chain_id: info.chain_id, wif, username: CHAIRMAN })
log(`логин ${CHAIRMAN_EMAIL}...`)
await client.login(CHAIRMAN_EMAIL, wif)
log(`setmaster ant → component «Минимальный продукт»`)
await client.Mutation(Mutations.Capital.SetMaster.mutation, {
variables: { data: { coopname: COOPNAME, project_hash: COMPONENT_HASH, master: CHAIRMAN } } as Mutations.Capital.SetMaster.IInput,
}).catch((e: any) => log(`SetMaster: ${e.message?.slice(0, 100) ?? e}`))
log(`setplan: 160 ч × 1500 RUB + 0 расходов`)
await client.Mutation(Mutations.Capital.SetPlan.mutation, {
variables: {
data: {
coopname: COOPNAME,
project_hash: COMPONENT_HASH,
plan_hours: 160,
hour_cost: '1500.0000 RUB',
plan_other_expenses: '0.0000 RUB',
},
} as Mutations.Capital.SetPlan.IInput,
}).catch((e: any) => log(`SetPlan: ${e.message?.slice(0, 100) ?? e}`))
log(`startproject → Active`)
await blockchain.api.transact({
actions: [{
account: 'capital',
name: 'startproject',
authorization: [{ actor: COOPNAME, permission: 'active' }],
data: { coopname: COOPNAME, project_hash: COMPONENT_HASH },
}],
}, { blocksBehind: 3, expireSeconds: 30 }).catch((e: any) => log(`startproject: ${e.message?.slice(0, 100) ?? e}`))
log('фаза 15 завершена — компонент Active, без коммитов')
}
@@ -1,38 +0,0 @@
/**
* Helper для интеграционных тестов walmove (operation `o.adj.walmove`).
*
* Перевод между кошельками одного бух.счёта через GraphQL-мутацию `walmoveWallets`.
* Backend подписывает action `ledger2::walmove` ключом председателя из vault,
* проверяя связь wallet→account по `Ledger2.LEDGER2_OPERATION_REGISTRY` ДО подписания.
*
* Возвращает {processHash, transactionId} — процесс-хэш можно использовать для
* последующей проверки записи в `getLedger2History`.
*/
import { gql, type LoginResult } from '../shared/apiClient'
interface WalmoveResponse {
walmoveWallets: { processHash: string; transactionId: string }
}
const WALMOVE_MUTATION = `mutation($i:WalmoveInput!){
walmoveWallets(input:$i){ processHash transactionId }
}`
export async function walmove(
login: LoginResult,
coopname: string,
username: string,
fromWallet: string,
toWallet: string,
quantity: string,
memo: string,
): Promise<{ processHash: string; transactionId: string }> {
const data = await gql<WalmoveResponse>(login.token, WALMOVE_MUTATION, {
i: { coopname, username, fromWallet, toWallet, quantity, memo },
})
return {
processHash: data.walmoveWallets.processHash,
transactionId: data.walmoveWallets.transactionId,
}
}
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
@@ -322,11 +322,7 @@ describe('тест контракта CAPITAL', () => {
expect(contributorTable.length).toBe(1)
const contributor = contributorTable[0]
expect(contributor.username).toBe(importUsername)
// ImportContributor создаёт запись со статусом IMPORT (см.
// capital/domain/entities/contributors.hpp Status::IMPORT). Перевод в
// ACTIVE — отдельным шагом (confirmreg/registerContributor); этот тест
// проверяет только сам факт импорта.
expect(contributor.status).toBe('import')
expect(contributor.status).toBe('active')
// Проверяем, что кошелек программы был создан
const wallet = await getUserProgramWalletAmount(blockchain, testCoop, importUsername, capitalProgramId)
+52 -161
View File
@@ -36,7 +36,6 @@ import { processFundProgram } from './capital/processFundProgram'
import { processRefreshProg } from './capital/processRefreshProg'
import { processFundProject } from './capital/processFundProject'
import { processRefreshProj } from './capital/processRefreshProj'
import { processRegShare } from './capital/processRegShare'
// const CLI_PATH = 'src/index.ts'
const blockchain = new Blockchain(config.network, config.private_keys)
@@ -67,21 +66,6 @@ const investAmount3 = 25000
let totalToCapitalConvertAmount = 0
let totalToProjectConvertAmount = 0
// Baseline ledger2-кошельков перед первым approvecmmt компонента-проекта.
// Нужен для инвариант-проверки «w.cap.gen (ЦПП «Генератор» — единый кошелёк программы) проекта
// закрылся в ноль» после полного цикла commits→voting→pushResult→signact2→
// convertsegm: approvecmmt(commit) кладёт на w.cap.gen commit.amounts.
// total_contribution, signact2(сегмента) забирает segment.available_for_program.
// Сумма выдач и приходов проекта обязана совпадать — иначе либо коммит попал
// на w.cap.gen неправильной суммой, либо часть пайщиков не сконвертировала.
//
// Имена кошельков — eosio::name. Финальный реестр (ADR-009): единые
// программные кошельки `w.cap.gen` и `w.cap.blago` вместо ранее декомпозированных.
const WALLET_GENERATOR_FUND = 'w.cap.gen'
const WALLET_BLAGOROST_FUND = 'w.cap.blago'
let generatorCommitBaselineRub = 0
let blagorostRidBaselineRub = 0
// Хранение хэшей долгов для каждого пользователя
let userDebtHashes: { [username: string]: string[] } = {}
beforeAll(async () => {
@@ -213,7 +197,7 @@ describe('тест контракта CAPITAL', () => {
fixed_membership_contribution: '0.0000 RUB',
membership_percent_fee: '0',
meta: '',
type: 'capital',
type: 'blagorost',
}
const result = await blockchain.api.transact(
@@ -654,25 +638,6 @@ describe('тест контракта CAPITAL', () => {
expect(project.status).toBe('active')
})
it('snapshot ledger2-кошельков перед первым approvecmmt компонента (baseline для инвариант-теста)', async () => {
// Берём baseline ровно перед первым approvecmmt componentProject:
// approvecmmt(tester1) ниже первым эмитит COMMIT_RID на w.cap.gen
// (ЦПП «Генератор» — единый кошелёк программы). Финальный assert «w.cap.gen проекта
// вернулся к baseline» сравнивает дельту по этому проекту, а не
// абсолютный остаток (на w.cap.gen могут быть остатки property-
// операций или будущих компонентов в одном тесте).
const wallets = await blockchain.getTableRows('ledger2', 'voskhod', 'wallets', 500) as Array<{ id: string, available: string, blocked: string }>
const generator = wallets.find((w) => String(w.id) === WALLET_GENERATOR_FUND)
const blagorost = wallets.find((w) => String(w.id) === WALLET_BLAGOROST_FUND)
generatorCommitBaselineRub = generator
? parseFloat(generator.available.split(' ')[0]) + parseFloat(generator.blocked.split(' ')[0])
: 0
blagorostRidBaselineRub = blagorost
? parseFloat(blagorost.available.split(' ')[0]) + parseFloat(blagorost.blocked.split(' ')[0])
: 0
console.log(`📌 baseline ${WALLET_GENERATOR_FUND}=${generatorCommitBaselineRub} ${WALLET_BLAGOROST_FUND}=${blagorostRidBaselineRub}`)
})
it('добавить коммит создателя tester1 на 10 часов по 1000 RUB', async () => {
const { finalProject, commitHash, commit } = await commitToResult(blockchain, 'voskhod', componentProject.project_hash, tester1, tester1CommitHours)
commits.push(commitHash)
@@ -1803,53 +1768,6 @@ describe('тест контракта CAPITAL', () => {
console.log(`✅ Сегмент ${investor3} обработан`)
})
it('инвариант: w.cap.gen (ЦПП «Генератор» — единый кошелёк программы) не уходит в дефицит после конвертации всех сегментов', async () => {
// Σ COMMIT_RID(коммитов проекта) = Σ commit.amounts.total_contribution
// должна быть >= Σ ACCEPT_RID(сегментов) = Σ segment.available_for_program.
//
// Эквивалентность (delta == 0) выполняется только когда **все** participants
// прошли полный цикл pushResult+signact2 без skipped/purge и contributors_bonus_pool
// распределился по всем ролям. В сложных тестах (как этот) есть пограничные
// ситуации: investor3 со статусом 'skipped' → purgesegment без convertsegm,
// тестеры с is_contributor=0 не получают свою долю contributors_bonus.
// Их непокрытая часть остаётся на w.cap.gen как избыток — это ожидаемо.
//
// **Жёсткий инвариант** — отсутствие дефицита (delta >= 0). Если он
// нарушен, значит approvecmmt кладёт на w.cap.gen меньше, чем signact2
// забирает — это регрессия патча approvecmmt, и в проде на signact2
// последнего пайщика проекта возникает «недостаточно средств на кошельке
// w.cap.gen». Защищает от ровно того бага, который ловили
// 2026-04-27 (delta_available вместо total_contribution).
const wallets = await blockchain.getTableRows('ledger2', 'voskhod', 'wallets', 500) as Array<{ id: string, available: string, blocked: string }>
const generator = wallets.find((w) => String(w.id) === WALLET_GENERATOR_FUND)
expect(generator, `wallet ${WALLET_GENERATOR_FUND} должен существовать`).toBeDefined()
const generatorTotalRub = parseFloat(generator!.available.split(' ')[0]) + parseFloat(generator!.blocked.split(' ')[0])
const generatorDelta = generatorTotalRub - generatorCommitBaselineRub
console.log(`🔍 ${WALLET_GENERATOR_FUND}: baseline=${generatorCommitBaselineRub} now=${generatorTotalRub} delta=${generatorDelta}`)
expect(generatorDelta, `${WALLET_GENERATOR_FUND} ушёл в дефицит (Σ COMMIT_RID < Σ ACCEPT_RID для проекта ${componentProject.project_hash.slice(0, 12)}…) — регрессия approvecmmt?`).toBeGreaterThanOrEqual(-0.0001)
if (generatorDelta > 0.0001) {
console.log(`⚠️ ${WALLET_GENERATOR_FUND} остаток ${generatorDelta.toFixed(4)} RUB — это нераспределённые доли (skipped-сегменты, роли без is_contributor); ожидаемо для этого сценария.`)
}
// Дополнительно: w.cap.blago (BLAGOROST_FUND) должен получить хоть какие-то
// средства (в проекте есть intellectual contribution, и хоть один сегмент
// дошёл до signact2). Точная сверка с total_contribution невозможна:
// signact2 параллельно вызывает REPAY (LOAN_ISSUED → SHARE_FUND_PAY)
// на debt_amount, который не идёт на w.cap.blago. Поэтому только sanity-чек,
// что путь ACCEPT_RID(w.cap.gen → w.cap.blago) вообще работает.
const project = await getProject(blockchain, 'voskhod', componentProject.project_hash)
const blagorost = wallets.find((w) => String(w.id) === WALLET_BLAGOROST_FUND)
const blagorostTotalRub = blagorost
? parseFloat(blagorost.available.split(' ')[0]) + parseFloat(blagorost.blocked.split(' ')[0])
: 0
const blagorostDelta = blagorostTotalRub - blagorostRidBaselineRub
const expectedTotalContribution = parseFloat(String(project.fact.total_contribution).split(' ')[0])
console.log(`🔍 ${WALLET_BLAGOROST_FUND} delta=${blagorostDelta}, project.fact.total_contribution=${expectedTotalContribution}`)
expect(blagorostDelta, `${WALLET_BLAGOROST_FUND} не получил никаких средств — путь ACCEPT_RID не работает`).toBeGreaterThan(0)
})
it('удаляем компонент-проект', async () => {
const result = await processDeleteProject(blockchain, 'voskhod', componentProject.project_hash)
@@ -1905,11 +1823,8 @@ describe('тест контракта CAPITAL', () => {
})
it('подписываем новое приложение к договору УХД со множеством участников', async () => {
// NOTE: apprvappndx больше НЕ вызывает regshare inline (коммит [562-13]
// da8c4436255): допуск к проекту и регистрация доли разведены.
// После приёма приложения participant имеет project_hash в appendixes,
// но сегмента в проекте ещё нет — его создаст отдельный regshare
// (следующий спек).
// NOTE: При одобрении appendix председателем автоматически вызывается regshare
// для всех участников с балансом благороста, регистрируя их долю в проекте
const testerNames = [tester1, tester2, tester3, tester4, tester5, investor1, investor2, investor3]
for (const tester of testerNames) {
const appendixHash = generateRandomSHA256()
@@ -1931,91 +1846,76 @@ describe('тест контракта CAPITAL', () => {
}
}, 1000_000)
it('регистрируем доли вкладчиков через capital::regshare отдельным действием', async () => {
// regshare теперь отдельное действие кооператива (см. signAppendix-спек выше).
// Кооп вызывает regshare после того, как participant подписал appendix к
// проекту: передаёт user_shares = баланс в целевой программе «Благорост»
// (available + blocked). Сегмент создаётся (или обновляется) с is_contributor=1.
it('проверяем что вклады участников с балансом благороста зарегистрированы автоматически', async () => {
// Список всех участников, которые подписали appendix
const allParticipants = [tester1, tester2, tester3, tester4, tester5, investor1, investor2, investor3]
console.log('\n=== ДИАГНОСТИКА: Проверка балансов благороста и сегментов ===\n')
let totalCapitalBalances = 0
const participantsWithCapital: { name: string, balance: string, amount: number }[] = []
let totalRegisteredInProject = 0
const participantsWithCapital = []
// Собираем всех participants с ненулевым балансом Благороста.
for (const participant of allParticipants) {
// Проверяем баланс в программе благороста
const capitalWallet = await getUserProgramWalletAmount(blockchain, 'voskhod', participant, capitalProgramId)
const capitalAmount = parseFloat(capitalWallet.split(' ')[0])
console.log(`${participant}: баланс Благороста = ${capitalWallet}`)
console.log(`\n${participant}:`)
console.log(` - Баланс в программе благороста: ${capitalWallet}`)
if (capitalAmount > 0) {
totalCapitalBalances += capitalAmount
participantsWithCapital.push({ name: participant, balance: capitalWallet, amount: capitalAmount })
participantsWithCapital.push(participant)
}
// Проверяем сегмент в проекте
try {
const segment = await getSegment(blockchain, 'voskhod', newComponentProject.project_hash, participant)
if (segment) {
console.log(` - Сегмент в проекте: ЕСТЬ`)
console.log(` - is_contributor: ${segment.is_contributor}`)
console.log(` - capital_contributor_shares: ${segment.capital_contributor_shares}`)
if (segment.is_contributor) {
const segmentCapital = parseFloat(segment.capital_contributor_shares.split(' ')[0])
totalRegisteredInProject += segmentCapital
// Проверяем что баланс в сегменте совпадает с балансом в программе
if (capitalAmount > 0) {
expect(segment.capital_contributor_shares).toBe(capitalWallet)
console.log(` ✅ Баланс в сегменте совпадает с балансом в программе`)
}
}
}
}
catch (error) {
console.log(` - Сегмент в проекте: НЕТ`)
}
}
// Регистрируем долю каждого вкладчика отдельным regshare-действием.
// (investor2 тоже имеет баланс в _capital_program — средства идут туда
// напрямую при инвестировании; итого — 8 вкладчиков.)
console.log('\n=== РЕГИСТРАЦИЯ ДОЛЕЙ (capital::regshare) ===\n')
for (const p of participantsWithCapital) {
await processRegShare(blockchain, 'voskhod', newComponentProject.project_hash, p.name, p.balance)
console.log(`regshare ${p.name}: ${p.balance}`)
}
// После regshare сегменты должны существовать и отражать баланс 1:1.
let totalRegisteredInProject = 0
for (const p of participantsWithCapital) {
const segment = await getSegment(blockchain, 'voskhod', newComponentProject.project_hash, p.name)
expect(segment, `Сегмент ${p.name} должен быть создан после regshare`).toBeDefined()
expect(segment.is_contributor, `${p.name}.is_contributor`).toBe(1)
expect(segment.capital_contributor_shares, `${p.name}.capital_contributor_shares`).toBe(p.balance)
const segmentCapital = parseFloat(segment.capital_contributor_shares.split(' ')[0])
totalRegisteredInProject += segmentCapital
}
console.log('\n=== ИТОГОВЫЕ СУММЫ ===')
console.log(`Участники с балансом благороста: ${participantsWithCapital.join(', ')}`)
console.log(`Сумма балансов в программе благороста: ${totalCapitalBalances.toFixed(4)} RUB`)
console.log(`Сумма зарегистрированная в проекте: ${totalRegisteredInProject.toFixed(4)} RUB`)
const projectState = await getProject(blockchain, 'voskhod', newComponentProject.project_hash)
console.log(`Сумма в проекте (total_capital_contributors_shares): ${projectState.crps.total_capital_contributors_shares}`)
const projectTotal = parseFloat(projectState.crps.total_capital_contributors_shares.split(' ')[0])
console.log(`\nРасхождение: ${(projectTotal - totalCapitalBalances).toFixed(4)} RUB`)
console.log('\n=== ИТОГОВЫЕ СУММЫ ===')
console.log(`Участники с балансом благороста: ${participantsWithCapital.map(p => p.name).join(', ')}`)
console.log(`Сумма балансов в программе благороста: ${totalCapitalBalances.toFixed(4)} RUB`)
console.log(`Сумма в сегментах проекта: ${totalRegisteredInProject.toFixed(4)} RUB`)
console.log(`total_capital_contributors_shares: ${projectState.crps.total_capital_contributors_shares}`)
// Инварианты CRPS: сумма долей в сегментах = баланс благороста;
// total_capital_contributors_shares проекта = то же значение.
expect(totalRegisteredInProject).toBeCloseTo(totalCapitalBalances, 4)
// Проверяем что сумма в проекте совпадает с суммой балансов участников
expect(projectTotal).toBeCloseTo(totalCapitalBalances, 4)
// Проверяем что зарегистрированы все участники с балансом благороста
// (investor2 теперь тоже имеет баланс в _capital_program т.к. средства идут туда напрямую при инвестировании)
expect(participantsWithCapital.length).toBe(8)
// expect(projectTotal).toBeCloseTo(768161.819, 3)
console.log(`Общая сумма в проекте: ${projectTotal.toFixed(4)} RUB (ожидалось около 768161.819)`)
console.log(`${participantsWithCapital.length} вкладчиков зарегистрированы через regshare, общая доля: ${projectTotal.toFixed(4)} RUB`)
}, 300_000)
it('regshare идемпотентен: повторный вызов обновляет user_shares', async () => {
// regshare.cpp: если сегмент существует — обновляется (upsert). Повторный
// вызов с тем же значением не должен ломать инварианты; с другим значением —
// обновляет capital_contributor_shares в сегменте и суммарно в проекте.
const participant = tester1
const walletBefore = await getUserProgramWalletAmount(blockchain, 'voskhod', participant, capitalProgramId)
const segmentBefore = await getSegment(blockchain, 'voskhod', newComponentProject.project_hash, participant)
expect(segmentBefore.capital_contributor_shares).toBe(walletBefore)
// Повторный regshare с тем же балансом — должен пройти без ошибок
// (upsert_contributor_segment перезаписывает значение).
// Спим, чтобы expiration новой транзакции отличался — иначе она получит
// тот же tx_hash и узел отклонит как duplicate transaction.
await sleep(2000)
await processRegShare(blockchain, 'voskhod', newComponentProject.project_hash, participant, walletBefore)
const segmentAfter = await getSegment(blockchain, 'voskhod', newComponentProject.project_hash, participant)
expect(segmentAfter.capital_contributor_shares).toBe(walletBefore)
expect(segmentAfter.is_contributor).toBe(1)
console.log(`✅ Повторный regshare идемпотентен: ${segmentAfter.capital_contributor_shares}`)
}, 60_000)
console.log(` Все ${participantsWithCapital.length} вкладчиков зарегистрированы корректно, общая сумма: ${projectTotal.toFixed(4)} RUB`)
})
it.skip('проверяем что повторная регистрация вкладчика невозможна', async () => {
await sleep(1000)
@@ -2437,21 +2337,12 @@ describe('тест контракта CAPITAL', () => {
console.log(`${p}: ${wallet}`)
}
// 2. Регистрация участников: signAppendix + regshare — отдельные действия.
// signAppendix добавляет project_hash в appendixes контрибьютора; сегмент
// в проекте не создаётся. regshare (capital::regshare) создаёт/обновляет
// сегмент с user_shares = баланс Благороста. Делаем regshare только для
// тех, у кого баланс > 0 — иначе проект не увидит их как contributor.
// 2. Регистрация участников
const participants = [tester1, tester2, this_investor, tester5]
for (const p of participants) {
await signAppendix(blockchain, 'voskhod', p, highPrecisionHash, generateRandomSHA256())
const balance = await getUserProgramWalletAmount(blockchain, 'voskhod', p, capitalProgramId)
const amount = parseFloat(balance.split(' ')[0])
if (amount > 0) {
await processRegShare(blockchain, 'voskhod', highPrecisionHash, p, balance)
}
const segment = await getSegment(blockchain, 'voskhod', highPrecisionHash, p)
console.log(`Сегмент ${p} после signAppendix+regshare (balance=${balance}):`, segment)
console.log(`Сегмент ${p} после signAppendix:`, segment)
}
const projectAfterReg = await getProject(blockchain, 'voskhod', highPrecisionHash)
+2 -12
View File
@@ -1,17 +1,7 @@
export const sourceProgramId = 3
export const capitalProgramId = 4
export const walletProgramId = 1
// draft_id для программных соглашений — соответствует мапе `program_map` из
// `lib/core/programs.hpp` (wallet→1, source/generator→0, capital/blagorost→1000).
// Используются при `wallet::signagree` (program_id > 0 после Эпика 2 / компонента 48
// ушло из soviet::sndagreement и подписывается через wallet).
export const walletDraftId = 1
export const sourceDraftId = 0
export const capitalDraftId = 1000
export const ratePerHour = '1000.0000 RUB'
export const sourceProgramName = 'generator'
export const capitalProgramName = 'capital'
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
export const circulationAccountId = 80_000
export const capitalProgramName = 'blagorost'
export const circulationAccountId = 80
@@ -1,48 +0,0 @@
import { expect } from 'vitest'
import { CapitalContract } from 'cooptypes'
import { getTotalRamUsage } from '../../utils/getTotalRamUsage'
/**
* Регистрирует долю пайщика в проекте через `capital::regshare`.
*
* С коммита `da8c4436255` ([562-13]) regshare отдельное действие кооператива,
* не inline из apprvappndx. Приложение к проекту и регистрация доли теперь
* разнесены по разным транзакциям: сначала participant подписывает appendix
* (getclearance + apprvappndx), затем кооператив отдельным действием
* регистрирует долю, равную балансу пайщика в целевой программе (Благорост).
*/
export async function processRegShare(
blockchain: any,
coopname: string,
projectHash: string,
username: string,
userShares: string,
) {
const data: CapitalContract.Actions.RegisterShare.IRegisterShare = {
coopname,
project_hash: projectHash,
username,
user_shares: userShares,
}
const result = await blockchain.api.transact(
{
actions: [
{
account: CapitalContract.contractName.production,
name: CapitalContract.Actions.RegisterShare.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data,
},
],
},
{
blocksBehind: 3,
expireSeconds: 30,
},
)
getTotalRamUsage(result)
expect(result.transaction_id).toBeDefined()
return result
}
@@ -1,20 +1,28 @@
import { signProgramAgreement } from '../wallet/signProgramAgreement'
import { capitalDraftId, capitalProgramId } from './consts'
import { expect } from 'vitest'
import { signAgreement } from '../soviet/signAgreement'
import { getCoopProgramWallet, getUserProgramWallet } from '../wallet/walletUtils'
import { capitalProgramName, sourceProgramId } from './consts'
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Благорост»
// (program_id=4) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
export async function signCapitalAgreement(
blockchain: any,
coopname: string,
username: string,
fakeDocument: any,
) {
return signProgramAgreement(
blockchain,
const txId = await signAgreement(blockchain, coopname, username, capitalProgramName, fakeDocument)
const wallet = await getUserProgramWallet(blockchain, coopname, username, sourceProgramId)
expect(wallet).toEqual(expect.objectContaining({
coopname,
username,
capitalProgramId,
capitalDraftId,
fakeDocument,
)
available: expect.any(String),
blocked: expect.any(String),
program_id: sourceProgramId,
membership_contribution: expect.any(String),
}))
const program = await getCoopProgramWallet(blockchain, coopname, sourceProgramId)
return { wallet, program, txId }
}
@@ -1,20 +1,28 @@
import { signProgramAgreement } from '../wallet/signProgramAgreement'
import { sourceDraftId, sourceProgramId } from './consts'
import { expect } from 'vitest'
import { signAgreement } from '../soviet/signAgreement'
import { getCoopProgramWallet, getUserProgramWallet } from '../wallet/walletUtils'
import { sourceProgramId, sourceProgramName } from './consts'
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Генератор»
// (program_id=3) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
export async function signGenerationContract(
blockchain: any,
coopname: string,
username: string,
fakeDocument: any,
) {
return signProgramAgreement(
blockchain,
const txId = await signAgreement(blockchain, coopname, username, sourceProgramName, fakeDocument)
const wallet = await getUserProgramWallet(blockchain, coopname, username, sourceProgramId)
expect(wallet).toEqual(expect.objectContaining({
coopname,
username,
sourceProgramId,
sourceDraftId,
fakeDocument,
)
available: expect.any(String),
blocked: expect.any(String),
program_id: sourceProgramId,
membership_contribution: expect.any(String),
}))
const program = await getCoopProgramWallet(blockchain, coopname, sourceProgramId)
return { wallet, program, txId }
}
@@ -1,461 +0,0 @@
/**
* Sanity + integration на L3 (Эпик 3 + Эпик 2 / story 2.1, 3.1, 3.2, 3.3, 3.6, 3.7).
*
* Часть 1 pure sanity (без блокчейна): cooptypes регенерированы под новые
* actions/tables (ledger2::userwallets, walletop с username, ledger2::migrate3,
* wallet::users, wallet::signagree/revokeagree/migrate3).
*
* Часть 2 integration на live-стенде:
* 2.1 wallet::signagree users[username].programs[] обновлён
* 2.2 wallet::revokeagree программа удалена; пустой vector запись users.erase
* 2.3 wallet::migrate3 идемпотентность (повторный вызов с теми же параметрами = no-op)
* 3.1 ledger2::migrate3 userwallets создаётся с правильными значениями
* 3.2 ledger2::migrate3 на COOPERATIVE-кошельке fail (assert проверки)
* 3.3 ledger2::migrate3 с (0,0) запись userwallets удаляется
* 3.4 walletop через apply на USER_SHARED БЕЗ соглашения в wallet::users fail
* 3.5 walletop через apply на USER_SHARED С соглашением в wallet::users success, L3 запись
*
* Запускать только после `pnpm run reboot:extra` тесты предполагают
* наличие cooperative=voskhod и chairman=ant в state.
*/
import { describe, expect, it, beforeAll } from 'vitest'
import { Ledger2, Ledger2Contract, WalletContract } from 'cooptypes'
import Blockchain from '../blockchain'
import config from '../configs'
import { generateRandomSHA256 } from '../utils/randomHash'
const COOP = 'voskhod'
const CHAIRMAN = 'ant'
describe('cooptypes L3 sanity (Эпик 2 / Эпик 3)', () => {
it('Ledger2Contract.Actions.Migrate3 экспортирует actionName=migrate3', () => {
expect(Ledger2Contract.Actions.Migrate3.actionName).toBe('migrate3')
})
it('Ledger2Contract.Actions.Walletop экспортирует actionName=walletop', () => {
expect(Ledger2Contract.Actions.Walletop.actionName).toBe('walletop')
})
it('Ledger2Contract.Tables.UserWallets экспортирует tableName=userwallets', () => {
expect(Ledger2Contract.Tables.UserWallets.tableName).toBe('userwallets')
})
it('WalletContract.Actions.SignAgreement.actionName=signagree', () => {
expect(WalletContract.Actions.SignAgreement.actionName).toBe('signagree')
})
it('WalletContract.Actions.RevokeAgreement.actionName=revokeagree', () => {
expect(WalletContract.Actions.RevokeAgreement.actionName).toBe('revokeagree')
})
it('WalletContract.Actions.Migrate3.actionName=migrate3 (wallet)', () => {
expect(WalletContract.Actions.Migrate3.actionName).toBe('migrate3')
})
it('WalletContract.Tables.Users.tableName=users', () => {
expect(WalletContract.Tables.Users.tableName).toBe('users')
})
it('OPERATION_REGISTRY содержит хотя бы одну USER_SHARED-операцию (с username dispatch)', () => {
const userSharedWallets = new Set([
'w.reg.minshr', 'w.wal.share', 'w.wal.member', 'w.cap.blago', 'w.cap.gen',
])
const hasUserShared = Ledger2.LEDGER2_OPERATION_REGISTRY.some((op: any) => {
return userSharedWallets.has(String(op.wallet_from)) || userSharedWallets.has(String(op.wallet_to))
})
expect(hasUserShared).toBe(true)
})
})
describe('ledger2 L3 + wallet::users — integration (live blockchain)', () => {
const bc = new Blockchain(config.network, config.private_keys)
const TEST_PROGRAM_ID_BLAGO = 4 // Благорост — есть в boot:extra
const TEST_USERNAME = 'ant'
beforeAll(async () => {
await bc.update_pass_instance()
}, 60_000)
it('wallet::signagree → users[username].programs[] обновляется', async () => {
const data: WalletContract.Actions.SignAgreement.ISignAgreement = {
coopname: COOP,
username: TEST_USERNAME,
program_id: TEST_PROGRAM_ID_BLAGO,
document: {
version: '0',
hash: generateRandomSHA256(),
doc_hash: generateRandomSHA256(),
meta_hash: '0000000000000000000000000000000000000000000000000000000000000000',
meta: '',
signatures: [],
},
draft_id: 0,
}
await bc.api.transact({
actions: [{
account: WalletContract.contractName.production,
name: WalletContract.Actions.SignAgreement.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows('wallet', COOP, 'users', 100)
const userRow = (rows as Array<{ username: string; programs: Array<{ program_id: number | string }> }>)
.find((r) => r.username === TEST_USERNAME)
expect(userRow, 'wallet::users должен иметь запись для пайщика').toBeDefined()
const hasProg = userRow!.programs.some((p) => Number(p.program_id) === TEST_PROGRAM_ID_BLAGO)
expect(hasProg, `programs[] должен содержать program_id=${TEST_PROGRAM_ID_BLAGO}`).toBe(true)
}, 60_000)
it('wallet::migrate3 идемпотентность (повторный вызов не дублирует programs[])', async () => {
// Повторный signagree с тем же program_id обновляет, не дублирует
const dupData: WalletContract.Actions.Migrate3.IMigrate3 = {
coopname: COOP,
username: TEST_USERNAME,
program_id: TEST_PROGRAM_ID_BLAGO,
doc_hash: generateRandomSHA256(),
version: 1,
draft_id: 0,
signed_at: '2026-05-05T00:00:00.000',
}
await bc.api.transact({
actions: [{
account: WalletContract.contractName.production,
name: WalletContract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data: dupData,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows('wallet', COOP, 'users', 100)
const userRow = (rows as Array<{ username: string; programs: Array<{ program_id: number | string }> }>)
.find((r) => r.username === TEST_USERNAME)
const dupCount = userRow!.programs.filter((p) => Number(p.program_id) === TEST_PROGRAM_ID_BLAGO).length
expect(dupCount, 'программы не должны дублироваться при повторном migrate3').toBe(1)
}, 60_000)
it('ledger2::migrate3 на USER_SHARED создаёт запись userwallets', async () => {
const data: Ledger2Contract.Actions.Migrate3.IMigrate3 = {
coopname: COOP,
wallet_name: 'w.cap.blago',
username: TEST_USERNAME,
available: '1000.0000 RUB',
blocked: '0.0000 RUB',
}
await bc.api.transact({
actions: [{
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows('ledger2', COOP, 'userwallets', 100)
const entry = (rows as Array<{ wallet_name: string; username: string; available: string }>)
.find((r) => r.wallet_name === 'w.cap.blago' && r.username === TEST_USERNAME)
expect(entry, 'userwallets[w.cap.blago, ant] должен быть создан').toBeDefined()
expect(entry!.available).toBe('1000.0000 RUB')
}, 60_000)
it('ledger2::migrate3 с (0, 0) удаляет запись userwallets', async () => {
const data: Ledger2Contract.Actions.Migrate3.IMigrate3 = {
coopname: COOP,
wallet_name: 'w.cap.blago',
username: TEST_USERNAME,
available: '0.0000 RUB',
blocked: '0.0000 RUB',
}
await bc.api.transact({
actions: [{
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows('ledger2', COOP, 'userwallets', 100)
const entry = (rows as Array<{ wallet_name: string; username: string }>)
.find((r) => r.wallet_name === 'w.cap.blago' && r.username === TEST_USERNAME)
expect(entry, 'userwallets[w.cap.blago, ant] должен быть удалён при (0,0)').toBeUndefined()
}, 60_000)
it('ledger2::migrate3 на COOPERATIVE-кошельке падает (assert kind == USER_SHARED)', async () => {
const data: Ledger2Contract.Actions.Migrate3.IMigrate3 = {
coopname: COOP,
wallet_name: 'w.reg.entry', // COOPERATIVE — нельзя L3
username: TEST_USERNAME,
available: '100.0000 RUB',
blocked: '0.0000 RUB',
}
await expect(
bc.api.transact({
actions: [{
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
).rejects.toThrow(/USER_SHARED/i)
}, 60_000)
it('wallet::revokeagree удаляет программу из vector; последняя → erase users', async () => {
// Удаляем оставшуюся программу — после revokeagree запись users должна исчезнуть.
const data: WalletContract.Actions.RevokeAgreement.IRevokeAgreement = {
coopname: COOP,
username: TEST_USERNAME,
program_id: TEST_PROGRAM_ID_BLAGO,
}
await bc.api.transact({
actions: [{
account: WalletContract.contractName.production,
name: WalletContract.Actions.RevokeAgreement.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows('wallet', COOP, 'users', 100)
const userRow = (rows as Array<{ username: string; programs: any[] }>)
.find((r) => r.username === TEST_USERNAME)
// Если нет других программ у этого пайщика — записи быть не должно.
if (userRow) {
const stillHas = userRow.programs.some((p) => Number(p.program_id) === TEST_PROGRAM_ID_BLAGO)
expect(stillHas, `program_id=${TEST_PROGRAM_ID_BLAGO} не должен оставаться в programs[]`).toBe(false)
}
}, 60_000)
})
/**
* Edge-кейсы вокруг wallet::users (signagree/revokeagree) и ledger2::migrate3.
* Каждый тест держит свой setup/cleanup, чтобы порядок исполнения не влиял
* на корректность; финальное состояние пайщика без program_id=BLAGO.
*/
describe('wallet::users / ledger2::migrate3 — edge cases (live blockchain)', () => {
const bc = new Blockchain(config.network, config.private_keys)
const TEST_PROGRAM_ID_BLAGO = 4
const TEST_PROGRAM_ID_GEN = 3 // Программа существует в boot:extra (Генератор)
const TEST_USERNAME = 'ant'
beforeAll(async () => {
await bc.update_pass_instance()
}, 60_000)
/** Гарантирует отсутствие program_id у пайщика (revokeagree если есть, иначе no-op). */
async function ensureNoProgram(program_id: number): Promise<void> {
const rows = await bc.getTableRows('wallet', COOP, 'users', 100)
const userRow = (rows as Array<{ username: string; programs: Array<{ program_id: number | string }> }>)
.find((r) => r.username === TEST_USERNAME)
if (!userRow) return
if (!userRow.programs.some((p) => Number(p.program_id) === program_id)) return
await bc.api.transact({
actions: [{
account: WalletContract.contractName.production,
name: WalletContract.Actions.RevokeAgreement.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data: { coopname: COOP, username: TEST_USERNAME, program_id },
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
async function signProgram(program_id: number): Promise<string> {
const doc_hash = generateRandomSHA256()
const data: WalletContract.Actions.SignAgreement.ISignAgreement = {
coopname: COOP,
username: TEST_USERNAME,
program_id,
document: {
version: '0',
hash: doc_hash,
doc_hash,
meta_hash: '0000000000000000000000000000000000000000000000000000000000000000',
meta: '',
signatures: [],
},
draft_id: 0,
}
await bc.api.transact({
actions: [{
account: WalletContract.contractName.production,
name: WalletContract.Actions.SignAgreement.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
return doc_hash
}
it('wallet::signagree повторно с тем же program_id обновляет doc_hash, не дублирует', async () => {
await ensureNoProgram(TEST_PROGRAM_ID_BLAGO)
await signProgram(TEST_PROGRAM_ID_BLAGO)
const secondHash = await signProgram(TEST_PROGRAM_ID_BLAGO)
const rows = await bc.getTableRows('wallet', COOP, 'users', 100)
const userRow = (rows as Array<{ username: string; programs: Array<{ program_id: number | string; doc_hash: string }> }>)
.find((r) => r.username === TEST_USERNAME)
expect(userRow).toBeDefined()
const blago = userRow!.programs.filter((p) => Number(p.program_id) === TEST_PROGRAM_ID_BLAGO)
expect(blago.length, 'программа не должна дублироваться при повторной подписи').toBe(1)
// Контракт нормализует hex-хэш в lowercase (см. document utils) — сравниваем без регистра.
expect(blago[0]!.doc_hash.toLowerCase(), 'doc_hash должен обновиться на последний').toBe(secondHash.toLowerCase())
await ensureNoProgram(TEST_PROGRAM_ID_BLAGO)
}, 60_000)
it('wallet::revokeagree без записи users → throws', async () => {
await ensureNoProgram(TEST_PROGRAM_ID_BLAGO)
await ensureNoProgram(TEST_PROGRAM_ID_GEN)
await expect(
bc.api.transact({
actions: [{
account: WalletContract.contractName.production,
name: WalletContract.Actions.RevokeAgreement.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data: { coopname: COOP, username: TEST_USERNAME, program_id: TEST_PROGRAM_ID_BLAGO },
}],
}, { blocksBehind: 3, expireSeconds: 30 })
).rejects.toThrow(/программных соглашений/i)
}, 60_000)
it('wallet::revokeagree program_id не в programs[] → throws', async () => {
await ensureNoProgram(TEST_PROGRAM_ID_GEN)
await signProgram(TEST_PROGRAM_ID_BLAGO)
await expect(
bc.api.transact({
actions: [{
account: WalletContract.contractName.production,
name: WalletContract.Actions.RevokeAgreement.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data: { coopname: COOP, username: TEST_USERNAME, program_id: TEST_PROGRAM_ID_GEN },
}],
}, { blocksBehind: 3, expireSeconds: 30 })
).rejects.toThrow(/не подписана/i)
await ensureNoProgram(TEST_PROGRAM_ID_BLAGO)
}, 60_000)
/** Гарантирует отсутствие L3-записи userwallets для (wallet_name, username). */
async function ensureNoUserWallet(wallet_name: string): Promise<void> {
const rows = await bc.getTableRows('ledger2', COOP, 'userwallets', 100)
const exists = (rows as Array<{ wallet_name: string; username: string }>)
.some((r) => r.wallet_name === wallet_name && r.username === TEST_USERNAME)
if (!exists) return
await bc.api.transact({
actions: [{
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data: {
coopname: COOP,
wallet_name,
username: TEST_USERNAME,
available: '0.0000 RUB',
blocked: '0.0000 RUB',
},
}],
}, { blocksBehind: 3, expireSeconds: 30 })
}
it('ledger2::migrate3 идемпотентность: два одинаковых вызова не падают и значения не дублируются', async () => {
await ensureNoUserWallet('w.cap.blago')
const data: Ledger2Contract.Actions.Migrate3.IMigrate3 = {
coopname: COOP,
wallet_name: 'w.cap.blago',
username: TEST_USERNAME,
available: '500.0000 RUB',
blocked: '0.0000 RUB',
}
await bc.api.transact({
actions: [{
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
await bc.api.transact({
actions: [{
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows('ledger2', COOP, 'userwallets', 100)
const matches = (rows as Array<{ wallet_name: string; username: string; available: string }>)
.filter((r) => r.wallet_name === 'w.cap.blago' && r.username === TEST_USERNAME)
expect(matches.length, 'L3-запись (wallet_name, username) должна быть единственной').toBe(1)
expect(matches[0]!.available).toBe('500.0000 RUB')
await ensureNoUserWallet('w.cap.blago')
}, 60_000)
it('ledger2::migrate3 с blocked > 0 сохраняет blocked-значение', async () => {
await ensureNoUserWallet('w.cap.blago')
const data: Ledger2Contract.Actions.Migrate3.IMigrate3 = {
coopname: COOP,
wallet_name: 'w.cap.blago',
username: TEST_USERNAME,
available: '1000.0000 RUB',
blocked: '250.0000 RUB',
}
await bc.api.transact({
actions: [{
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows('ledger2', COOP, 'userwallets', 100)
const entry = (rows as Array<{ wallet_name: string; username: string; available: string; blocked: string }>)
.find((r) => r.wallet_name === 'w.cap.blago' && r.username === TEST_USERNAME)
expect(entry).toBeDefined()
expect(entry!.available).toBe('1000.0000 RUB')
expect(entry!.blocked).toBe('250.0000 RUB')
await ensureNoUserWallet('w.cap.blago')
}, 60_000)
it('ledger2::migrate3 (0, 0) когда записи нет → no-op (не падает, ничего не создаёт)', async () => {
await ensureNoUserWallet('w.cap.blago')
const data: Ledger2Contract.Actions.Migrate3.IMigrate3 = {
coopname: COOP,
wallet_name: 'w.cap.blago',
username: TEST_USERNAME,
available: '0.0000 RUB',
blocked: '0.0000 RUB',
}
await bc.api.transact({
actions: [{
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows('ledger2', COOP, 'userwallets', 100)
const exists = (rows as Array<{ wallet_name: string; username: string }>)
.some((r) => r.wallet_name === 'w.cap.blago' && r.username === TEST_USERNAME)
expect(exists, '(0,0) не должен создавать запись').toBe(false)
}, 60_000)
})
@@ -1,300 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import Blockchain from '../blockchain'
import config from '../configs'
import { globalRamStats } from '../utils/getTotalRamUsage'
import { generateRandomSHA256 } from '../utils/randomHash'
import { sleep } from '../utils'
import { LedgerAccountType } from './wallet/walletUtils'
// =============================================================================
// Epic 1 addendum (пересмотр 2026-04-20): миграция переведена на 6 прямых
// TRANSIT_* проводок без транзитного счёта 99 и без кошелька CASH_MAIN.
// 2026-04-27: numeric wallet id → eosio::name w.<contract>.<waltype>.
//
// Семантика:
// TRANSIT_MIN_SHARE → Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND (w.reg.minshr)
// TRANSIT_BLAGOROST → Dr 51 / Cr 80, ISSUE BLAGOROST_FUND (w.cap.blago)
// TRANSIT_SHARE → Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY (w.wal.share)
// TRANSIT_ENTRY → Dr 51 / Cr 86, ISSUE ENTRANCE_FEES (w.reg.entry)
// TRANSIT_COMMITMENT → Dr 08 / Cr 80, ISSUE GENERATOR_FUND (w.cap.gen)
//
// План счетов: 04, 08, 51, 58, 80, 86 (99 удалён как лишний транзит).
// РИД-перенос (w.wal.sharid + o.mig.rid) удалён в Story 1.1: legacy 80 без РИД-части (ADR-009).
// =============================================================================
const LEDGER = 'ledger'
const LEDGER2 = 'ledger2'
const COOP = 'voskhod'
const LEGACY_ACCOUNTS = {
BANK_ACCOUNT: 51,
SHARE_FUND: 80,
ENTRANCE_FEES: 861,
} as const
const LEDGER2_WALLETS = {
SHARE_FUND_PAY: 'w.wal.share',
MIN_SHARE_FUND: 'w.reg.minshr',
ENTRANCE_FEES: 'w.reg.entry',
} as const
const LEDGER2_ACCOUNTS = {
INTANGIBLE_ASSETS: 4_000,
NON_CURRENT_INVESTMENTS: 8_000,
BANK_ACCOUNT: 51_000,
FINANCIAL_INVESTMENTS: 58_000,
SHARE_FUND: 80_000,
TARGET_RECEIPTS: 86_000,
} as const
const blockchain = new Blockchain(config.network, config.private_keys)
async function ledgerAdd(accountId: number, quantity: string, hash: string, username: string) {
return blockchain.api.transact(
{
actions: [
{
account: LEDGER,
name: 'add',
authorization: [{ actor: COOP, permission: 'active' }],
data: { coopname: COOP, account_id: accountId, quantity, comment: 'migration fixture', hash, username },
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
}
async function getLegacyLaccount(accountId: number) {
const rows = await blockchain.getTableRows(LEDGER, COOP, 'accounts', 200)
return rows.find((row: any) => Number(row.id) === accountId)
}
async function getLedger2Wallet(walletName: string) {
const rows = await blockchain.getTableRows(LEDGER2, COOP, 'wallets', 200)
return rows.find((row: any) => String(row.id) === walletName)
}
async function getLedger2Account(accountId: number) {
const rows = await blockchain.getTableRows(LEDGER2, COOP, 'accounts', 500)
return rows.find((row: any) => Number(row.id) === accountId)
}
async function getLedger2Meta() {
const rows = await blockchain.getTableRows(LEDGER2, LEDGER2, 'meta', 10)
return rows[0]
}
async function migrate(from_coop_index = 0, limit = Number.MAX_SAFE_INTEGER) {
return blockchain.api.transact(
{
actions: [
{
account: LEDGER2,
name: 'migrate',
authorization: [{ actor: LEDGER2, permission: 'active' }],
data: { from_coop_index, limit },
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
}
function rubAmount(value: number): string {
return `${value.toFixed(4)} RUB`
}
function parseAssetAmount(raw: unknown): number {
if (!raw) return 0
const [v] = String(raw).split(' ')
return Number.parseFloat(v)
}
beforeAll(async () => {
await blockchain.update_pass_instance()
}, 500_000)
afterAll(() => {
console.log('\n📊 **RAM USAGE SUMMARY (ledger2-migrate)** 📊')
let total = 0
for (const [key, ram] of Object.entries(globalRamStats)) {
console.log(` ${key} = ${(ram / 1024).toFixed(2)} kb`)
total += ram
}
console.log(`\n💾 TOTAL: ${(total / 1024).toFixed(2)} kb\n`)
})
describe('ledger2::migrate (пересмотр 2026-05-05: 3 TRANSIT_* без 99/CASH_MAIN/РИД)', () => {
// Типовой кейс «без РИД»: cash_legacy = entry_legacy + share_money.
// share_legacy = share_money (legacy 80 без РИД-части — обязательное условие ADR-009).
const seedCash = 5_000 // → Dr 51 суммарно
const seedEntry = 1_500 // → Cr 86 (через TRANSIT_ENTRY)
const seedShare = seedCash - seedEntry // 3_500 → Cr 80 (распределится на MIN+SHARE, без РИД)
let baselineCashAcc = 0
let baselineShareAcc = 0
let baselineEntryAcc = 0
let baselineWalletsTotal = 0
let migrateWasAlreadyDone = false
it('seed: пишем остатки в legacy-ledger (51, 80, 861)', async () => {
const metaBefore = await getLedger2Meta()
migrateWasAlreadyDone = Boolean(metaBefore && Boolean(metaBefore.migrated))
if (migrateWasAlreadyDone) {
console.log('⏩ migrate уже выполнялся ранее — seed не пишем')
return
}
baselineCashAcc = parseAssetAmount((await getLedger2Account(LEDGER2_ACCOUNTS.BANK_ACCOUNT))?.debit_balance)
baselineShareAcc = parseAssetAmount((await getLedger2Account(LEDGER2_ACCOUNTS.SHARE_FUND))?.credit_balance)
baselineEntryAcc = parseAssetAmount((await getLedger2Account(LEDGER2_ACCOUNTS.TARGET_RECEIPTS))?.credit_balance)
// Baseline Σ wallets (w.wal.share + w.reg.minshr + w.reg.entry): testnet-boot мог создать
// начальные балансы для пайщиков. AC8 проверяет что seed добавил ровно
// seedCash сверху baseline.
const sharePayW = await getLedger2Wallet(LEDGER2_WALLETS.SHARE_FUND_PAY)
const minShareW = await getLedger2Wallet(LEDGER2_WALLETS.MIN_SHARE_FUND)
const entryW = await getLedger2Wallet(LEDGER2_WALLETS.ENTRANCE_FEES)
baselineWalletsTotal =
parseAssetAmount(sharePayW?.available) +
parseAssetAmount(minShareW?.available) +
parseAssetAmount(entryW?.available)
await ledgerAdd(LEGACY_ACCOUNTS.BANK_ACCOUNT, rubAmount(seedCash), generateRandomSHA256(), 'ant')
await ledgerAdd(LEGACY_ACCOUNTS.SHARE_FUND, rubAmount(seedShare), generateRandomSHA256(), 'ant')
await ledgerAdd(LEGACY_ACCOUNTS.ENTRANCE_FEES, rubAmount(seedEntry), generateRandomSHA256(), 'ant')
const cash = await getLegacyLaccount(LEGACY_ACCOUNTS.BANK_ACCOUNT)
const share = await getLegacyLaccount(LEGACY_ACCOUNTS.SHARE_FUND)
const entry = await getLegacyLaccount(LEGACY_ACCOUNTS.ENTRANCE_FEES)
expect(parseAssetAmount(cash?.available)).toBeCloseTo(seedCash, 4)
expect(parseAssetAmount(share?.available)).toBeCloseTo(seedShare, 4)
expect(parseAssetAmount(entry?.available)).toBeCloseTo(seedEntry, 4)
})
it('AC1: migrate(0, MAX) выполняется (или уже выполнен)', async () => {
const metaBefore = await getLedger2Meta()
if (!metaBefore || !Boolean(metaBefore.migrated)) {
const res = await migrate()
expect(res.transaction_id).toBeDefined()
} else {
console.log('⏩ migrate уже выполнен (migrated_coops =', metaBefore.migrated_coops, ')')
}
const meta = await getLedger2Meta()
expect(meta).toBeDefined()
expect(Boolean(meta.migrated)).toBe(true)
expect(Number(meta.migrated_coops)).toBeGreaterThan(0)
})
it('AC2: account 51 (BANK_ACCOUNT, ACTIVE) получил весь seedCash на debit', async () => {
const acc = await getLedger2Account(LEDGER2_ACCOUNTS.BANK_ACCOUNT)
expect(acc).toBeDefined()
expect(Number(acc.account_type)).toBe(LedgerAccountType.ACTIVE)
if (!migrateWasAlreadyDone) {
expect(parseAssetAmount(acc.debit_balance)).toBeCloseTo(baselineCashAcc + seedCash, 4)
}
})
it('AC3: account 80 (SHARE_FUND, PASSIVE) получил seedShare на credit (прямой Cr 80)', async () => {
const acc = await getLedger2Account(LEDGER2_ACCOUNTS.SHARE_FUND)
expect(acc).toBeDefined()
expect(Number(acc.account_type)).toBe(LedgerAccountType.PASSIVE)
if (!migrateWasAlreadyDone) {
expect(parseAssetAmount(acc.credit_balance)).toBeCloseTo(baselineShareAcc + seedShare, 4)
}
})
it('AC4: account 86 (TARGET_RECEIPTS, PASSIVE) получил seedEntry на credit (через TRANSIT_ENTRY)', async () => {
const acc = await getLedger2Account(LEDGER2_ACCOUNTS.TARGET_RECEIPTS)
expect(acc).toBeDefined()
expect(Number(acc.account_type)).toBe(LedgerAccountType.PASSIVE)
if (!migrateWasAlreadyDone) {
expect(parseAssetAmount(acc.credit_balance)).toBeCloseTo(baselineEntryAcc + seedEntry, 4)
}
})
it('AC5: счёт 99 (OPENING_TRANSIT) больше не существует — пересмотр 2026-04-20 удалил его из плана счетов', async () => {
// Старая схема делала 99 как транзит; новая — прямые проводки. Запись 99 в
// accounts2 не должна появляться вообще. Проверка через getTableRows на id=99_000.
const rows = await blockchain.getTableRows(LEDGER2, COOP, 'accounts', 500)
const legacy99 = rows.find((row: any) => Number(row.id) === 99_000)
expect(legacy99, 'account 99_000 (OPENING_TRANSIT) не должен существовать после migrate').toBeUndefined()
})
it('AC6: балансы accounts2 консистентны — 51 (Dr) == 80 + 86 (Cr) после миграции', async () => {
const cashAcc = await getLedger2Account(LEDGER2_ACCOUNTS.BANK_ACCOUNT)
const shareAcc = await getLedger2Account(LEDGER2_ACCOUNTS.SHARE_FUND)
const entryAcc = await getLedger2Account(LEDGER2_ACCOUNTS.TARGET_RECEIPTS)
expect(cashAcc, 'BANK_ACCOUNT (51) не найден после migrate').toBeDefined()
expect(shareAcc, 'SHARE_FUND (80) не найден после migrate').toBeDefined()
expect(entryAcc, 'TARGET_RECEIPTS (86) не найден после migrate').toBeDefined()
// Consistency инвариант: balance == ±(debit_balance credit_balance).
expect(parseAssetAmount(cashAcc.balance)).toBeCloseTo(parseAssetAmount(cashAcc.debit_balance) - parseAssetAmount(cashAcc.credit_balance), 4)
expect(parseAssetAmount(shareAcc.balance)).toBeCloseTo(parseAssetAmount(shareAcc.credit_balance) - parseAssetAmount(shareAcc.debit_balance), 4)
expect(parseAssetAmount(entryAcc.balance)).toBeCloseTo(parseAssetAmount(entryAcc.credit_balance) - parseAssetAmount(entryAcc.debit_balance), 4)
})
it('AC7: wallet CASH_MAIN больше НЕ существует — убран из плана кошельков', async () => {
// Пересмотр 2026-04-20: CASH_MAIN удалён, счёт 51 ведётся только в accounts2.
// Никаких wallet-записей под устаревшим именем быть не должно.
const rows = await blockchain.getTableRows(LEDGER2, COOP, 'wallets', 200)
const legacyCashMain = rows.find((row: any) => String(row.id) === 'w.wal.cash')
expect(legacyCashMain, 'wallet CASH_MAIN не должен существовать после migrate').toBeUndefined()
})
it('AC8: Σ wallets (w.wal.share + w.reg.minshr + w.reg.entry) == baseline + seedCash (инвариант миграции)', async () => {
if (migrateWasAlreadyDone) {
console.log('⏩ migrate был ранее — AC8 проверка Σ wallets пропущена (неизвестен baseline)')
return
}
const sharePayWallet = await getLedger2Wallet(LEDGER2_WALLETS.SHARE_FUND_PAY)
const minShareWallet = await getLedger2Wallet(LEDGER2_WALLETS.MIN_SHARE_FUND)
const entryWallet = await getLedger2Wallet(LEDGER2_WALLETS.ENTRANCE_FEES)
const totalWallets =
parseAssetAmount(sharePayWallet?.available) +
parseAssetAmount(minShareWallet?.available) +
parseAssetAmount(entryWallet?.available)
// Инвариант: seed добавил ровно seedCash в Σ money-кошельков над baseline.
// Baseline снимался ДО seed (в первом it) — теперь baseline + seedCash.
// Благорост (w.cap.blago) и Генератор (w.cap.gen) в эту сумму не входят — они
// emplace-ятся отдельно из soviet::progwallets без участия в бух-проводках.
expect(totalWallets).toBeCloseTo(baselineWalletsTotal + seedCash, 4)
})
it('AC9: повторный migrate() после полного прогона → тихий no-op', async () => {
const metaBefore = await getLedger2Meta()
expect(metaBefore).toBeDefined()
expect(Boolean(metaBefore.migrated)).toBe(true)
const cashBefore = parseAssetAmount(
(await getLedger2Account(LEDGER2_ACCOUNTS.BANK_ACCOUNT))?.debit_balance,
)
const shareBefore = parseAssetAmount(
(await getLedger2Account(LEDGER2_ACCOUNTS.SHARE_FUND))?.credit_balance,
)
await sleep(1000)
const res = await migrate()
expect(res.transaction_id).toBeDefined()
const metaAfter = await getLedger2Meta()
expect(Boolean(metaAfter.migrated)).toBe(true)
expect(Number(metaAfter.migrated_coops)).toBe(Number(metaBefore.migrated_coops))
expect(Number(metaAfter.last_migrated_coop_index)).toBe(Number(metaBefore.last_migrated_coop_index))
const cashAfter = parseAssetAmount(
(await getLedger2Account(LEDGER2_ACCOUNTS.BANK_ACCOUNT))?.debit_balance,
)
const shareAfter = parseAssetAmount(
(await getLedger2Account(LEDGER2_ACCOUNTS.SHARE_FUND))?.credit_balance,
)
expect(cashAfter).toBeCloseTo(cashBefore, 4)
expect(shareAfter).toBeCloseTo(shareBefore, 4)
})
})
@@ -1,186 +0,0 @@
/**
* Story 1.23 integration: ledger2 read-layer через GraphQL.
*
* Проверяет, что controller-резолверы `getLedger2Accounts`/`getLedger2Wallets`/
* `getLedger2History` возвращают актуальный срез по кооперативу в тот же
* момент, когда on-chain таблицы ledger2::accounts/wallets содержат данные.
*
* Почему важно: Epic 2 (генераторы отчётов) и desktop читают ТОЛЬКО через
* SDK (не напрямую из цепочки), поэтому регрессия в read-слое скрывается
* от unit-тестов сервиса и всплывает уже в отчётах/кабинете.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import Blockchain from '../blockchain'
import config from '../configs'
import { gql, loginAsChairman } from './shared/apiClient'
const COOP = 'voskhod'
// Идентификаторы плана счетов (пересмотр 2026-04-20): ×1000 offset.
const ACCOUNT_BANK = 51_000
const ACCOUNT_SHARE_FUND = 80_000
const ACCOUNT_TARGET = 86_000
const ACCOUNT_TYPE_ACTIVE = 0
const ACCOUNT_TYPE_PASSIVE = 1
// Кошельки кооператива после миграции (пересмотр 2026-04-20: CASH_MAIN удалён;
// 2026-04-27: numeric id → eosio::name w.<contract>.<waltype>).
const WALLET_SHARE_FUND_PAY = 'w.wal.share'
const WALLET_ENTRANCE_FEES = 'w.reg.entry'
const ACCOUNTS_QUERY = `query($c:String!){
getLedger2Accounts(coopname:$c){ id name balance debitBalance creditBalance accountType }
}`
const WALLETS_QUERY = `query($c:String!){
getLedger2Wallets(coopname:$c){ id name available blocked }
}`
const HISTORY_QUERY = `query($i:GetLedger2HistoryInput!){
getLedger2History(input:$i){
items { action operationCode processHash accountId quantity }
totalCount totalPages currentPage
}
}`
const bc = new Blockchain(config.network, config.private_keys)
let token = ''
function parseAssetAmount(raw: unknown): number {
if (!raw) return 0
const [v] = String(raw).split(' ')
return Number.parseFloat(v)
}
beforeAll(async () => {
await bc.update_pass_instance()
token = (await loginAsChairman()).token
}, 60_000)
afterAll(() => {})
describe('Ledger2 read-layer (Story 1.23)', () => {
it('getLedger2Accounts: 51/80/86 получены с корректными типами и balance = ±(DrCr)', async () => {
const data = await gql<{ getLedger2Accounts: any[] }>(token, ACCOUNTS_QUERY, { c: COOP })
const accounts = data.getLedger2Accounts
expect(Array.isArray(accounts)).toBe(true)
expect(accounts.length).toBeGreaterThanOrEqual(3)
const byId = new Map<number, any>(accounts.map((a) => [Number(a.id), a]))
const bank = byId.get(ACCOUNT_BANK)
expect(bank, '51000 должен быть после migrate').toBeDefined()
expect(bank.accountType).toBe(ACCOUNT_TYPE_ACTIVE)
expect(parseAssetAmount(bank.balance)).toBeCloseTo(
parseAssetAmount(bank.debitBalance) - parseAssetAmount(bank.creditBalance),
4,
)
const share = byId.get(ACCOUNT_SHARE_FUND)
expect(share, '80000 должен быть после migrate').toBeDefined()
expect(share.accountType).toBe(ACCOUNT_TYPE_PASSIVE)
expect(parseAssetAmount(share.balance)).toBeCloseTo(
parseAssetAmount(share.creditBalance) - parseAssetAmount(share.debitBalance),
4,
)
const target = byId.get(ACCOUNT_TARGET)
expect(target, '86000 должен быть после migrate').toBeDefined()
expect(target.accountType).toBe(ACCOUNT_TYPE_PASSIVE)
expect(parseAssetAmount(target.balance)).toBeCloseTo(
parseAssetAmount(target.creditBalance) - parseAssetAmount(target.debitBalance),
4,
)
// Счёт 99 OPENING_TRANSIT полностью удалён в пересмотре 2026-04-20,
// новая миграция идёт прямыми Dr 51 / Cr 80/86 без транзита.
const transit = byId.get(99_000)
expect(transit, 'счёт 99 (OPENING_TRANSIT) больше не должен существовать').toBeUndefined()
}, 30_000)
it('getLedger2Accounts: срез совпадает с on-chain ledger2::accounts (scope=coopname)', async () => {
const onchain = await bc.getTableRows('ledger2', COOP, 'accounts', 500)
const data = await gql<{ getLedger2Accounts: any[] }>(token, ACCOUNTS_QUERY, { c: COOP })
const gqlAccounts = data.getLedger2Accounts
expect(gqlAccounts.length).toBe(onchain.length)
for (const chainRow of onchain) {
const match = gqlAccounts.find((a) => Number(a.id) === Number(chainRow.id))
expect(match, `счёт ${chainRow.id} отсутствует в GraphQL-срезе`).toBeDefined()
expect(parseAssetAmount(match.balance)).toBeCloseTo(parseAssetAmount(chainRow.balance), 4)
expect(parseAssetAmount(match.debitBalance)).toBeCloseTo(
parseAssetAmount(chainRow.debit_balance),
4,
)
expect(parseAssetAmount(match.creditBalance)).toBeCloseTo(
parseAssetAmount(chainRow.credit_balance),
4,
)
}
}, 30_000)
it('getLedger2Wallets: кошельки кооператива с актуальным available/blocked', async () => {
const data = await gql<{ getLedger2Wallets: any[] }>(token, WALLETS_QUERY, { c: COOP })
const wallets = data.getLedger2Wallets
expect(Array.isArray(wallets)).toBe(true)
expect(wallets.length).toBeGreaterThanOrEqual(3)
const byId = new Map<string, any>(wallets.map((w) => [String(w.id), w]))
expect(byId.get(WALLET_SHARE_FUND_PAY), 'кошелёк паевых взносов w.wal.share').toBeDefined()
expect(byId.get(WALLET_ENTRANCE_FEES), 'кошелёк вступ.взносов w.reg.entry').toBeDefined()
// CASH_MAIN удалён пересмотром 2026-04-20 — счёт 51 ведётся только в accounts2.
expect(byId.get('w.wal.cash'), 'устаревший CASH_MAIN отсутствует в реестре').toBeUndefined()
const onchain = await bc.getTableRows('ledger2', COOP, 'wallets', 500)
for (const w of onchain) {
const match = byId.get(String(w.id))
if (!match) continue
expect(parseAssetAmount(match.available)).toBeCloseTo(parseAssetAmount(w.available), 4)
expect(parseAssetAmount(match.blocked)).toBeCloseTo(parseAssetAmount(w.blocked), 4)
}
}, 30_000)
it('getLedger2History: непустая история + фильтр по operationCodes работает', async () => {
const full = await gql<{ getLedger2History: any }>(token, HISTORY_QUERY, {
i: { coopname: COOP, limit: 20, page: 1 },
})
const resp = full.getLedger2History
expect(resp.totalCount).toBeGreaterThan(0)
expect(resp.items.length).toBeGreaterThan(0)
// Каждая операция — одного из типов blockchain-actions ledger2:
// штатное трио apply/walletop/debit/credit + adjustment-actions walmove/revert.
for (const op of resp.items) {
expect(['apply', 'walletop', 'debit', 'credit', 'walmove', 'revert']).toContain(op.action)
}
// Фильтр по operationCodes: если есть o.mig.share (транзит паевого фонда), то
// после фильтрации все записи — только apply с этим кодом.
const filtered = await gql<{ getLedger2History: any }>(token, HISTORY_QUERY, {
i: { coopname: COOP, operationCodes: ['o.mig.share'], limit: 10, page: 1 },
})
const fresp = filtered.getLedger2History
expect(fresp.totalCount).toBeLessThanOrEqual(resp.totalCount)
for (const op of fresp.items) {
expect(op.operationCode).toBe('o.mig.share')
expect(op.action).toBe('apply')
}
}, 30_000)
it('getLedger2History: accountId + actionNames=[debit,credit] = только проводки по этому счёту', async () => {
// По дизайну фильтра (typeorm-ledger2-state.repository): accountId без
// actionNames возвращает все sibling-actions процесса (apply+walletop+
// debit+credit), которые ХОТЬ КАК-ТО касаются счёта (включая wallet_to=80000
// в одном процессе). Чтобы получить ТОЛЬКО прямые проводки по 51000 —
// нужно явно указать actionNames=['debit','credit'].
const filtered = await gql<{ getLedger2History: any }>(token, HISTORY_QUERY, {
i: { coopname: COOP, accountId: ACCOUNT_BANK, actionNames: ['debit', 'credit'], limit: 20, page: 1 },
})
const resp = filtered.getLedger2History
for (const op of resp.items) {
expect(Number(op.accountId)).toBe(ACCOUNT_BANK)
expect(['debit', 'credit']).toContain(op.action)
}
}, 30_000)
})
@@ -1,251 +0,0 @@
/**
* Sanity + integration на финальный реестр кошельков и упрощённый WalletOp
* (Super-story 1.1+1.2+1.3 эпика «L3 в ledger2»).
*
* Часть 1 pure sanity (без блокчейна): cooptypes-зеркало контрактного реестра
* соответствует архитектуре (ADR-002 / ADR-003 / ADR-009 / ADR-010):
* Финальный набор кошельков (5 USER_SHARED + 6 COOPERATIVE = 11)
* WalletOp без WALLET_ONLY/REVOKE, с BURN
* TRANSFER без бухпроводок маркируется (debit==null && credit==null)
* НЕ через специальный op-code (o.cap.invest)
* Capital операции используют только унифицированные кошельки
* w.cap.{blago,gen,loan} никаких старых bginv/gncom/bgrid
* Все wallet_from/wallet_to стандартных операций реестр кошельков
*
* Часть 2 integration: blockchain ledger2::wallets после migrate содержит
* только имена из финального реестра (никаких stale-имён) и accounts2 имеет
* корректную структуру.
*/
import { describe, expect, it, beforeAll } from 'vitest'
import { Ledger2 } from 'cooptypes'
import Blockchain from '../blockchain'
import config from '../configs'
const COOP = 'voskhod'
const LEDGER2 = 'ledger2'
// ── Финальный реестр (ADR-002, ADR-009): эталонные имена ──────────────────────
const EXPECTED_USER_SHARED = [
'w.reg.minshr',
'w.wal.share',
'w.wal.member',
'w.cap.blago',
'w.cap.gen',
] as const
const EXPECTED_COOPERATIVE = [
'w.reg.entry',
'w.wal.wthdrw',
'w.sov.infra',
'w.sov.delgte',
'w.cap.loan',
'w.mkt.payout',
] as const
const ALL_VALID_NAMES = new Set<string>([...EXPECTED_USER_SHARED, ...EXPECTED_COOPERATIVE])
// Имена, которые мы умышленно удалили — не должны встречаться нигде в реестре
// или в blockchain wallets table.
const FORBIDDEN_LEGACY_NAMES = [
'w.wal.sharid',
'w.cap.bginv',
'w.cap.gncom',
'w.cap.bgrid',
'w.wal.cash',
] as const
const EXPECTED_WALLET_OPS = ['ISSUE', 'TRANSFER', 'BLOCK', 'UNBLOCK', 'BURN'] as const
describe('ledger2 wallet registry — sanity (cooptypes ↔ архитектура)', () => {
it('LEDGER2_WALLET_REGISTRY содержит ровно 11 кошельков (5 USER_SHARED + 6 COOPERATIVE)', () => {
expect(Ledger2.LEDGER2_WALLET_REGISTRY).toHaveLength(11)
const userShared = Ledger2.LEDGER2_WALLET_REGISTRY.filter((w) => w.kind === 'USER_SHARED').map((w) => w.name)
const cooperative = Ledger2.LEDGER2_WALLET_REGISTRY.filter((w) => w.kind === 'COOPERATIVE').map((w) => w.name)
expect(userShared.sort()).toEqual([...EXPECTED_USER_SHARED].sort())
expect(cooperative.sort()).toEqual([...EXPECTED_COOPERATIVE].sort())
})
it('никаких удалённых stale-имён (sharid/bginv/gncom/bgrid/wal.cash) в реестре', () => {
const names = Ledger2.LEDGER2_WALLET_REGISTRY.map((w) => w.name)
for (const forbidden of FORBIDDEN_LEGACY_NAMES) {
expect(names, `wallet ${forbidden} должен быть удалён из реестра`).not.toContain(forbidden)
}
})
it('getWalletKind возвращает корректный kind для каждого имени', () => {
for (const name of EXPECTED_USER_SHARED) {
expect(Ledger2.getWalletKind(name)).toBe('USER_SHARED')
}
for (const name of EXPECTED_COOPERATIVE) {
expect(Ledger2.getWalletKind(name)).toBe('COOPERATIVE')
}
expect(Ledger2.getWalletKind('w.wal.sharid')).toBeUndefined()
expect(Ledger2.getWalletKind(null)).toBeUndefined()
expect(Ledger2.getWalletKind('')).toBeUndefined()
})
it('USER_SHARED имена соответствуют конвенции w.<contract>.<waltype> (ADR-010 compile-time)', () => {
const userShared = Ledger2.LEDGER2_WALLET_REGISTRY.filter((w) => w.kind === 'USER_SHARED')
for (const w of userShared) {
expect(w.name, `USER_SHARED имя должно начинаться с w.`).toMatch(/^w\.[a-z]{2,3}\.[a-z0-9]+$/)
}
})
})
describe('ledger2 OPERATION_REGISTRY — sanity (WalletOp cleanup, ADR-003 / ADR-009)', () => {
it('WalletOp в каждой записи ∈ {ISSUE, TRANSFER, BLOCK, UNBLOCK, BURN} null (adjustment)', () => {
for (const op of Ledger2.LEDGER2_OPERATION_REGISTRY) {
if (op.kind === 'adjustment') {
expect(op.wallet_op, `adjustment ${op.code} должна иметь wallet_op == null`).toBeNull()
} else {
expect(EXPECTED_WALLET_OPS, `${op.code}: WalletOp ${op.wallet_op} устарел или неизвестен`).toContain(op.wallet_op)
}
}
})
it('никаких упоминаний устаревших WALLET_ONLY/REVOKE в реестре операций', () => {
for (const op of Ledger2.LEDGER2_OPERATION_REGISTRY) {
expect(op.wallet_op as unknown, `${op.code}: WALLET_ONLY должен быть удалён`).not.toBe('WALLET_ONLY')
expect(op.wallet_op as unknown, `${op.code}: REVOKE должен быть удалён, заменён BURN`).not.toBe('REVOKE')
}
})
it('все wallet_from/wallet_to стандартных операций ⊆ финальный реестр (плюс null)', () => {
for (const op of Ledger2.LEDGER2_OPERATION_REGISTRY) {
if (op.kind === 'adjustment') continue
if (op.wallet_from !== null) {
expect(ALL_VALID_NAMES, `${op.code}: wallet_from ${op.wallet_from} не из финального реестра`).toContain(op.wallet_from)
}
if (op.wallet_to !== null) {
expect(ALL_VALID_NAMES, `${op.code}: wallet_to ${op.wallet_to} не из финального реестра`).toContain(op.wallet_to)
}
}
})
it('zero_accounts_iff_both: debit==null ⇔ credit==null (ADR-003)', () => {
for (const op of Ledger2.LEDGER2_OPERATION_REGISTRY) {
if (op.kind === 'adjustment') continue
const debitNull = op.debit === null
const creditNull = op.credit === null
expect(debitNull, `${op.code}: debit/credit должны оба быть null или оба заданы`).toBe(creditNull)
}
})
it('o.cap.invest — TRANSFER без бухпроводок (заменил WALLET_ONLY, ADR-009)', () => {
const invest = Ledger2.LEDGER2_OPERATION_REGISTRY.find((o) => o.code === 'o.cap.invest')
expect(invest, 'операция o.cap.invest должна существовать').toBeDefined()
expect(invest!.wallet_op).toBe('TRANSFER')
expect(invest!.debit).toBeNull()
expect(invest!.credit).toBeNull()
expect(invest!.wallet_from).toBe('w.wal.share')
expect(invest!.wallet_to).toBe('w.cap.blago')
})
it('capital операции используют только унифицированные w.cap.{blago,gen,loan} (ADR-009)', () => {
const allowedCapitalWallets = new Set(['w.cap.blago', 'w.cap.gen', 'w.cap.loan', 'w.wal.share'])
const capitalOps = Ledger2.LEDGER2_OPERATION_REGISTRY.filter((o) => o.contract === 'capital')
expect(capitalOps.length, 'capital должен иметь ≥5 операций после ADR-009').toBeGreaterThanOrEqual(5)
for (const op of capitalOps) {
for (const w of [op.wallet_from, op.wallet_to]) {
if (!w) continue
expect(allowedCapitalWallets, `${op.code}: capital использует устаревший кошелёк ${w}`).toContain(w)
}
}
})
it('o.mig.rid удалён из реестра — миграция не сплитит legacy 80 на share+rid', () => {
const migRid = Ledger2.LEDGER2_OPERATION_REGISTRY.find((o) => o.code === 'o.mig.rid')
expect(migRid, 'операция o.mig.rid должна быть удалена (Super-story 1.1)').toBeUndefined()
})
it('adjustment операции (o.adj.*) имеют kind=adjustment и null-параметры', () => {
const adjustments = Ledger2.LEDGER2_OPERATION_REGISTRY.filter((o) => o.kind === 'adjustment')
expect(adjustments.length).toBeGreaterThanOrEqual(2) // o.adj.walmove + o.adj.rev
for (const op of adjustments) {
expect(op.wallet_op).toBeNull()
expect(op.wallet_from).toBeNull()
expect(op.wallet_to).toBeNull()
expect(op.debit).toBeNull()
expect(op.credit).toBeNull()
expect(op.code).toMatch(/^o\.adj\./)
}
expect(adjustments.map((a) => a.code).sort()).toEqual(['o.adj.rev', 'o.adj.walmove'])
})
})
// ── Часть 2: integration с живым blockchain ───────────────────────────────────
describe('ledger2 wallet registry — integration (blockchain ledger2::wallets)', () => {
const bc = new Blockchain(config.network, config.private_keys)
beforeAll(async () => {
await bc.update_pass_instance()
}, 60_000)
it('on-chain wallets ⊆ финальный реестр (нет stale-имён, нет вне-реестровых)', async () => {
// На свежем чейне таблица может быть пустой до migrate / первой регистрации —
// это валидно. Проверяем строго: всё что есть, должно быть из финального реестра.
const rows = await bc.getTableRows(LEDGER2, COOP, 'wallets', 200)
const onChainNames = (rows as Array<{ id: string }>).map((r) => String(r.id))
for (const forbidden of FORBIDDEN_LEGACY_NAMES) {
expect(onChainNames, `blockchain содержит устаревший wallet ${forbidden}`).not.toContain(forbidden)
}
for (const onChain of onChainNames) {
expect(ALL_VALID_NAMES, `blockchain содержит wallet ${onChain} вне финального реестра`).toContain(onChain)
}
}, 60_000)
it('после регистрации пайщика (spread_initial=true) появляются w.reg.minshr (USER_SHARED) и w.reg.entry (COOPERATIVE)', async () => {
// Прямой adduser с spread_initial=true — это единственный путь, где registrator::adduser
// эмитит ОБЕ операции: o.reg.putmin (USER_SHARED w.reg.minshr) + o.reg.payent (COOPERATIVE w.reg.entry).
// addUser2 helper хардкодит spread_initial=false и отсекает PAY_ENTRANCE, поэтому идём напрямую.
const { RegistratorContract } = await import('cooptypes')
const { generateRandomUsername } = await import('../utils/randomUsername')
const { generateRandomSHA256 } = await import('../utils/randomHash')
const username = generateRandomUsername()
const account = await bc.generateKeypair(username, undefined, 'Аккаунт участника')
const data: RegistratorContract.Actions.AddUser.IAddUser = {
coopname: COOP,
referer: '',
username: account.username,
type: 'individual',
created_at: '2025-02-05T09:34:27',
initial: '100.0000 RUB',
minimum: '100.0000 RUB',
spread_initial: true,
meta: '',
registration_hash: generateRandomSHA256(),
}
await bc.api.transact({
actions: [
{
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.AddUser.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
},
],
}, { blocksBehind: 3, expireSeconds: 30 })
const rows = await bc.getTableRows(LEDGER2, COOP, 'wallets', 200)
const onChainNames = new Set((rows as Array<{ id: string }>).map((r) => String(r.id)))
expect(onChainNames.has('w.reg.minshr'), 'w.reg.minshr должен появиться после регистрации (ISSUE PUT_MINSHARE)').toBe(true)
expect(onChainNames.has('w.reg.entry'), 'w.reg.entry должен появиться после регистрации (ISSUE PAY_ENTRANCE)').toBe(true)
// Регрессия: после регистрации НЕ должно быть никаких stale-имён.
for (const forbidden of FORBIDDEN_LEGACY_NAMES) {
expect(onChainNames.has(forbidden), `stale ${forbidden} не должен появиться`).toBe(false)
}
}, 240_000)
})
@@ -1,263 +0,0 @@
/**
* Epic 4 integration: ProcessRegistryService end-to-end.
*
* Покрывает цепочку "контракт parser blockchain_actions/deltas
* ProcessRegistryService GraphQL". Unit-тесты проверяют мапинг локатора
* и ACTION_CODE_TO_PROCESS_TYPE на моках; здесь что в боевой цепочке
* blockchain_actions действительно содержит hash в UPPERCASE и LOWER-aware
* лукап через резолвер возвращает корректный процесс с actions[] и
* entity-delta candidates2.
*
* Требует запущенной среды (chain + parser + controller).
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { RegistratorContract, WalletContract, GatewayContract } from 'cooptypes'
import Blockchain from '../blockchain'
import config from '../configs'
import { generateRandomSHA256 } from '../utils/randomHash'
import { generateRandomUsername } from '../utils/randomUsername'
import { gql, loginAsChairman, waitUntil } from './shared/apiClient'
import { signWalletAgreement } from './wallet/signWalletAgreement'
import { fakeDocument } from './shared/fakeDocument'
const COOP = 'voskhod'
const PROCESS_QUERY = `query($h:String!,$c:String!){
process(hash:$h, coopname:$c){
process_type
process_hash
coopname
actions { account name data }
delta_history { code table primary_key }
documents { hash }
}
}`
const PROCESSES_QUERY = `query($f:ProcessesFilter!, $p:PaginationInput!){
processes(filter:$f, pagination:$p){
items { processType processHash coopname username firstSeenAt lastSeenAt }
totalCount
}
}`
const blockchain = new Blockchain(config.network, config.private_keys)
let token = ''
beforeAll(async () => {
await blockchain.update_pass_instance()
token = (await loginAsChairman()).token
// (B) wall.deposit требует у `ant` подписанного wallet-соглашения —
// иначе soviet::addbal (inline из wallet::completedpst) падает
// «Кошелёк не найден». signAgreement идемпотентен: если agreement уже
// подписан на свежей цепочке — молча пройдёт, ошибка контракта
// говорит о other issue. Ловим повторную подписку мягко.
try {
await signWalletAgreement(blockchain, COOP, 'ant', fakeDocument)
} catch (err: any) {
if (!/уже подписано|already signed/i.test(err?.message ?? '')) {
throw err
}
}
}, 60_000)
afterAll(() => {
// Никакого teardown: тестовые пайщики оседают в цепочке — это ОК для smoke.
})
describe('ProcessRegistry end-to-end (Epic 4)', () => {
it('(A) reg.regist: adduser → 2 ledger2::apply в одном процессе (entrfee + minshare)', async () => {
const username = generateRandomUsername()
const registration_hash = generateRandomSHA256()
const data: RegistratorContract.Actions.AddUser.IAddUser = {
coopname: COOP,
referer: '',
username,
type: 'individual',
created_at: '2025-01-15T10:00:00',
initial: '100.0000 RUB',
minimum: '200.0000 RUB',
spread_initial: true,
meta: 'integration-test',
registration_hash,
}
await blockchain.api.transact(
{
actions: [
{
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.AddUser.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data,
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
const view = await waitUntil<any>(
async () => {
try {
const res = await gql<{ process: any }>(token, PROCESS_QUERY, {
h: registration_hash,
c: COOP,
})
const p = res.process
if (!p) return null
// p.reg.accept = 2 × (apply + walletop + debit + credit) = 8 ledger2-actions.
// Ждём, пока parser сохранит всю обойму, иначе ассерты ниже могут
// увидеть промежуточное состояние (два apply уже есть, а sibling
// walletop/debit/credit ещё догоняют).
const ledgerActions = (p.actions || []).filter((a: any) => a.account === 'ledger2')
return ledgerActions.length >= 8 ? p : null
} catch {
return null
}
},
{ timeoutMs: 90_000, intervalMs: 1000, label: 'p.reg.accept parser catch-up' },
)
expect(view.process_type).toBe('p.reg.accept')
// Hash в ответе — в исходном lowercase (сервис явно нормализует).
expect(view.process_hash.toLowerCase()).toBe(registration_hash.toLowerCase())
expect(view.coopname).toBe(COOP)
// Два inline ledger2::apply с одним process_hash и разными action_code.
const applyActions = view.actions.filter(
(a: any) => a.account === 'ledger2' && a.name === 'apply',
)
expect(applyActions.length).toBe(2)
const codes = applyActions.map((a: any) => a.data.operation_code).sort()
expect(codes).toEqual(['o.reg.payent', 'o.reg.putmin'])
// Под каждый apply есть парные walletop/debit/credit = ещё 3×2 = 6 actions.
const sideActions = view.actions.filter(
(a: any) => a.account === 'ledger2' && a.name !== 'apply',
)
expect(sideActions.length).toBeGreaterThanOrEqual(6)
// Phase B: entity-дельта candidates2. Parser→controller передача
// entity-дельт (candidates2/deposits/results/...) не всегда доезжает
// в blockchain_deltas за время теста (часть write-путей parser публикует
// в Mongo, но не публикует в Redis stream до controller). Тест
// подтверждает лишь Phase A (actions) — это основное покрытие Epic 4.
// Если дельта успела долететь — проверим, что она про candidates2.
const candidate = view.delta_history.find(
(d: any) => d.code === 'registrator' && d.table === 'candidates2',
)
if (candidate) {
expect(candidate.primary_key).toBeDefined()
}
}, 180_000)
it('(B) p.wal.depo: CreateDeposit+CompleteIncome → apply o.wal.depcpl + deposits delta', async () => {
// Депозит в кошелёк провайдера: createdpst → completedpst
// завершает и эмитит ledger2::apply(o.wal.depcpl) + walletop/debit/credit.
const deposit_hash = generateRandomSHA256()
const quantity = '42.0000 RUB'
await blockchain.api.transact(
{
actions: [
{
account: WalletContract.contractName.production,
name: WalletContract.Actions.CreateDeposit.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data: { coopname: COOP, username: 'ant', deposit_hash, quantity },
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
await blockchain.api.transact(
{
actions: [
{
account: GatewayContract.contractName.production,
name: GatewayContract.Actions.CompleteIncome.actionName,
authorization: [{ actor: COOP, permission: 'active' }],
data: { coopname: COOP, income_hash: deposit_hash },
},
],
},
{ blocksBehind: 3, expireSeconds: 30 },
)
const view = await waitUntil<any>(
async () => {
try {
const res = await gql<{ process: any }>(token, PROCESS_QUERY, {
h: deposit_hash,
c: COOP,
})
const p = res.process
if (!p) return null
// p.wal.depo = 1 × (apply + walletop + debit + credit) = 4 ledger2-actions.
// Ждём полной обоймы, чтобы не увидеть промежуточное состояние.
const ledgerActions = (p.actions || []).filter((a: any) => a.account === 'ledger2')
return ledgerActions.length >= 4 ? p : null
} catch {
return null
}
},
{ timeoutMs: 90_000, intervalMs: 1000, label: 'p.wal.depo parser catch-up' },
)
expect(view.process_type).toBe('p.wal.depo')
const apply = view.actions.find(
(a: any) => a.account === 'ledger2' && a.name === 'apply',
)
expect(apply, 'ledger2::apply должен быть').toBeDefined()
expect(apply.data.operation_code).toBe('o.wal.depcpl')
// Phase B entity-дельта wallet::deposits. См. комментарий в тесте (A):
// parser→controller передача entity-дельт пока не все полностью
// насыщает blockchain_deltas — основной assert здесь phase A.
const depositsDeltas = view.delta_history.filter(
(d: any) => d.code === 'wallet' && d.table === 'deposits',
)
if (depositsDeltas.length > 0) {
expect(depositsDeltas[0].primary_key).toBeDefined()
}
}, 180_000)
it('(C) processes listing: только что созданные процессы видны в пагинации', async () => {
const res = await gql<{ processes: { items: any[]; totalCount: number } }>(
token,
PROCESSES_QUERY,
{
f: { coopname: COOP },
p: { page: 1, limit: 50 },
},
)
expect(res.processes.totalCount).toBeGreaterThanOrEqual(2)
// Элементы — уникальные процессы по (process_hash, coopname);
// p.cap.rid и p.reg.accept — многоактные процессы должны быть
// ОДНОЙ строкой в items, не по количеству apply.
const hashes = new Set(res.processes.items.map((i) => i.processHash.toLowerCase()))
expect(hashes.size).toBe(res.processes.items.length)
const types = new Set(res.processes.items.map((i) => i.processType))
// После (A) и (B) у нас минимум p.reg.accept + p.wal.depo в листинге.
expect(types.has('p.reg.accept') || types.has('p.wal.depo')).toBe(true)
}, 30_000)
it('(D) validation: невалидный hash → BadRequest', async () => {
await expect(
gql(token, PROCESS_QUERY, { h: 'not-hex-64', c: COOP }),
).rejects.toThrow(/hex-64|gql failed/i)
})
it('(E) not-found: валидный hash без apply-якоря → ошибка', async () => {
const fake = 'f'.repeat(64)
await expect(gql(token, PROCESS_QUERY, { h: fake, c: COOP })).rejects.toThrow(
/не найден|gql failed/i,
)
})
})
@@ -5,25 +5,18 @@ import { getTotalRamUsage } from '../../utils/getTotalRamUsage'
import { processDecision } from '../soviet/processDecision'
import { getParticipant } from '../registrator/getParticipant'
import { fakeDocument } from '../soviet/fakeDocument'
import { compareTokenAmounts, getLedgerAccountById } from '../wallet/walletUtils'
// Идентификаторы счетов ledger2 (id = code × 1000). После рефакторинга
// Epic 1 проверка балансов идёт по двойной записи на 80 (паевой фонд) и
// 86 (целевое финансирование), а не по legacy fund::coopwallet —
// последний обновлялся через Ledger::add, который удалён вместе с переходом
// на ledger2.apply.
const LEDGER2_SHARE_FUND_ID = 80_000 // 80 — Паевой фонд (passive)
const LEDGER2_TARGET_RECEIPTS_ID = 86_000 // 86 — Целевое финансирование (passive)
import { compareTokenAmounts, getCoopWallet } from '../wallet/walletUtils'
export async function registerUser(blockchain: any, coopname: string, username: string) {
const registration_hash = generateRandomSHA256()
const account = await blockchain.generateKeypair(username, undefined, 'Аккаунт кооператива')
// 🔹 Получаем балансы до регистрации (по двойной записи ledger2)
const prevShareFund = await getLedgerAccountById(blockchain, coopname, LEDGER2_SHARE_FUND_ID)
const prevTargetReceipts = await getLedgerAccountById(blockchain, coopname, LEDGER2_TARGET_RECEIPTS_ID)
console.log('📦 ledger2 BEFORE: 80=', prevShareFund.available, '86=', prevTargetReceipts.available)
// 🔹 Получаем балансы до регистрации
const prevCoopWallet = await getCoopWallet(blockchain, coopname)
const prevAvailable = prevCoopWallet?.circulating_account?.available || '0.0000 RUB'
const prevInitial = prevCoopWallet?.initial_account?.available || '0.0000 RUB'
console.log('📦 CoopWallet BEFORE:', prevCoopWallet)
// 🔹 1. Создание аккаунта
{
@@ -113,20 +106,18 @@ export async function registerUser(blockchain: any, coopname: string, username:
expect(participant.status).toBe('accepted')
expect(participant.username).toBe(username)
// 🔹 6. Проверка балансов после регистрации (двойная запись ledger2)
// confirmreg → 2 × Ledger2::apply:
// - operations::registrator::PUT_MINSHARE (Dr 51 / Cr 80, +minimum на 80)
// - operations::registrator::PAY_ENTRANCE (Dr 51 / Cr 86, +initial на 86)
// Оба счёта пассивные → balance = credit debit; растёт при кредитовании.
const newShareFund = await getLedgerAccountById(blockchain, coopname, LEDGER2_SHARE_FUND_ID)
const newTargetReceipts = await getLedgerAccountById(blockchain, coopname, LEDGER2_TARGET_RECEIPTS_ID)
console.log('💰 ledger2 AFTER: 80=', newShareFund.available, '86=', newTargetReceipts.available)
// 🔹 6. Проверка балансов после регистрации
const coopWalletAfter = await getCoopWallet(blockchain, coopname)
const newAvailable = coopWalletAfter?.circulating_account?.available || '0.0000 RUB'
const newInitial = coopWalletAfter?.initial_account?.available || '0.0000 RUB'
console.log('💰 CoopWallet AFTER:', coopWalletAfter)
const minAmount = parseFloat(participant.minimum_amount.split(' ')[0])
const initAmount = parseFloat(participant.initial_amount.split(' ')[0])
compareTokenAmounts(prevShareFund.available, newShareFund.available, minAmount)
compareTokenAmounts(prevTargetReceipts.available, newTargetReceipts.available, initAmount)
compareTokenAmounts(prevAvailable, newAvailable, minAmount)
compareTokenAmounts(prevInitial, newInitial, initAmount)
return participant
}

Some files were not shown because too many files have changed in this diff Show More