Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c31190966 | |||
| 23d0e519ce |
@@ -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
|
||||
|
||||
@@ -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-портам выше.
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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 }}"}'
|
||||
@@ -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 }}"}'
|
||||
@@ -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 }}
|
||||
@@ -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 }}"}'
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 L1–L9:**
|
||||
- **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.1–3.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).
|
||||
@@ -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 (~5–6 минут)
|
||||
|
||||
`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
@@ -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,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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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() : '—'}`,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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` в корне проекта):
|
||||
|
||||
@@ -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`.
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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)."
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 "Перезапуск завершен!"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "Перезапуск завершен!"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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('Дополнительная инициализация для расширенного режима выполнена')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ±(Dr−Cr)', 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
Reference in New Issue
Block a user