Merge branch 'marketplace2' of github.com:coopenomics/mono into marketplace2
This commit is contained in:
@@ -1,3 +1,41 @@
|
||||
# Не тащить в 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
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID
|
||||
|
||||
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"
|
||||
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
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] dicoop/bootcoop:${{ env.DOCKER_TAG }} (sha=${GITHUB_SHA::7})"
|
||||
|
||||
- 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] Ошибка сборки dicoop/bootcoop на ${{ github.ref_name }} (sha=${GITHUB_SHA::7})"
|
||||
@@ -88,10 +88,20 @@ jobs:
|
||||
docker push dicoop/coopback:latest
|
||||
fi
|
||||
|
||||
# Build parser image удалён: парсер переехал в отдельный репозиторий
|
||||
# coopenomics/parser и публикуется как dicoop/parser из его release.yml.
|
||||
# Двойной источник одного образа создаёт коллизию тегов, поэтому
|
||||
# здесь сборку парсера больше не делаем.
|
||||
# TEMP: парсер используется существующим деплоем как dicoop/cooparser,
|
||||
# пока не мигрировали на dicoop/parser из отдельного coopenomics/parser repo.
|
||||
# Оставляем сборку и пуш образа до завершения миграции потребителей.
|
||||
- name: Build parser image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
|
||||
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
|
||||
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
|
||||
docker push dicoop/cooparser:latest
|
||||
fi
|
||||
|
||||
- name: Build notificator image
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
name: Build contracts container
|
||||
|
||||
# Сборка и публикация docker-образа `dicoop/contracts`, который содержит
|
||||
# wasm/abi всех контрактов кооперативной экономики (apps, marketplace,
|
||||
# ledger, meet, loan, wallet, capital, branch, contributor, fund, draft,
|
||||
# soviet, registrator, gateway, system, test). Образ потребляется
|
||||
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
|
||||
# контракты через `cleos set contract`.
|
||||
#
|
||||
# Триггер — push в одну из веток-окружений (dev / testnet / main).
|
||||
# Маппинг:
|
||||
# dev → IS_TESTNET=ON, tag=dev
|
||||
# testnet → IS_TESTNET=ON, tag=testnet
|
||||
# main → IS_TESTNET=OFF, tag=main + latest
|
||||
#
|
||||
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
|
||||
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
|
||||
# Артефакты копируются в slim alpine-образ.
|
||||
#
|
||||
# Секреты:
|
||||
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN — публикация в dicoop/*
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main]
|
||||
paths:
|
||||
- 'components/contracts/cpp/**'
|
||||
- 'components/contracts/CMakeLists.txt'
|
||||
- 'components/contracts/build.sh'
|
||||
- 'components/contracts/build-all.sh'
|
||||
- 'components/contracts/docker/**'
|
||||
- '.github/workflows/build-contracts.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build mode and docker tag
|
||||
run: |
|
||||
case "${{ github.ref_name }}" in
|
||||
main)
|
||||
echo "BUILD_MODE=prod" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=main" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
|
||||
;;
|
||||
testnet)
|
||||
echo "BUILD_MODE=test" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
;;
|
||||
dev)
|
||||
echo "BUILD_MODE=test" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported branch ${{ github.ref_name }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile all contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
- name: Stage docker context
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
ROOT="docker/.context"
|
||||
rm -rf "$ROOT"
|
||||
mkdir -p "$ROOT/contracts"
|
||||
|
||||
# Имена контрактов берём из CMakeLists (`add_contract_build(<name>)`)
|
||||
# — единая точка истины. Любое изменение списка там автоматически
|
||||
# подтянется в образ.
|
||||
# Имена контрактов = аргументы 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"
|
||||
|
||||
# Хелпер: упаковывает одну пару (wasm, abi) в /contracts/<name>/
|
||||
# и добавляет JSON-запись в STDOUT (caller склеивает в массив).
|
||||
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' "$GITHUB_SHA"
|
||||
printf ' "ref": "%s",\n' "$GITHUB_REF_NAME"
|
||||
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"
|
||||
# как имя пропускаем. test_contracts/* не упаковываем —
|
||||
# это тестовые stub'ы, не нужны на проде.
|
||||
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"
|
||||
echo "--- context tree ---"
|
||||
find "$ROOT" -maxdepth 3 -type f | sort
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
IMAGE="dicoop/contracts"
|
||||
docker build \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$DOCKER_TAG" \
|
||||
./docker/.context
|
||||
docker push "$IMAGE:$DOCKER_TAG"
|
||||
|
||||
if [ -n "$EXTRA_TAG" ]; then
|
||||
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$EXTRA_TAG"
|
||||
docker push "$IMAGE:$EXTRA_TAG"
|
||||
fi
|
||||
|
||||
# SHA-tag для воспроизводимости (если нужно запинить конкретный
|
||||
# коммит, а не двигающийся branch-tag).
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
|
||||
docker push "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
|
||||
|
||||
- name: Verify pushed image
|
||||
run: |
|
||||
docker run --rm dicoop/contracts:$DOCKER_TAG list
|
||||
echo "---"
|
||||
docker run --rm dicoop/contracts:$DOCKER_TAG sha256
|
||||
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
COUNT=$(docker run --rm dicoop/contracts:$DOCKER_TAG list | wc -l)
|
||||
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] dicoop/contracts:$DOCKER_TAG ($COUNT контрактов, mode=$BUILD_MODE, sha=${GITHUB_SHA::7})"
|
||||
|
||||
- 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] Ошибка сборки dicoop/contracts на ${{ github.ref_name }} (sha=${GITHUB_SHA::7})"
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
- main
|
||||
- testnet
|
||||
- dev
|
||||
- reports
|
||||
- marketplace2
|
||||
|
||||
jobs:
|
||||
build-and-publish-docs:
|
||||
@@ -78,26 +80,28 @@ jobs:
|
||||
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
|
||||
|
||||
# ── standards-site → /standards/ на docs.coopenomics.world ───────
|
||||
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
|
||||
# публикуется как поддиректория на том же домене. Vite собирает
|
||||
# с base='/standards/' (см. vite.config.ts), относительные пути
|
||||
# внутри dist/ и hash-router работают корректно.
|
||||
- name: Build standards-site
|
||||
run: pnpm run build
|
||||
working-directory: ./components/contracts/standards-site
|
||||
|
||||
- name: Copy standards-site into docs site/standards
|
||||
run: |
|
||||
mkdir -p ./components/docs/site/standards
|
||||
cp -r ./components/contracts/standards-site/dist/* ./components/docs/site/standards/
|
||||
|
||||
- name: Remove specific large file before publishing
|
||||
run: |
|
||||
# Удаляем конкретный большой файл sdk/typedoc.json
|
||||
|
||||
@@ -18,11 +18,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Та же мажорная линия pnpm, что и lockfile (lockfileVersion 6.0 = pnpm 8).
|
||||
# Иначе `npm i -g pnpm` тянет последний pnpm и переписывает pnpm-lock.yaml → Lerna EUNCOMMIT.
|
||||
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0 = pnpm 9/10).
|
||||
# Иначе action-setup может подтянуть другую версию и переписать pnpm-lock.yaml → Lerna EUNCOMMIT.
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.8
|
||||
version: 10.33.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -19,3 +19,5 @@ components/reports-standarts/**/СФР_1118018397_*.xml
|
||||
|
||||
# Output files for local audit scripts (ledger2 migration)
|
||||
components/contracts/cpp/ledger2/scripts/out/
|
||||
|
||||
.env.testnet
|
||||
|
||||
+89
-37
@@ -1,49 +1,101 @@
|
||||
# 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
|
||||
|
||||
RUN npm install -g pnpm lerna --no-fund --no-audit
|
||||
|
||||
# Сначала манифесты (для кэш-friendly install). `.dockerignore` уже
|
||||
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
|
||||
COPY . .
|
||||
|
||||
# Устанавливаем инструменты
|
||||
RUN npm install -g pnpm lerna
|
||||
# Используем существующий lockfile. Если он не совпадает с workspace —
|
||||
# падать сразу, не дрейфовать незаметно.
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Установка зависимостей
|
||||
# Используем версию pnpm, совместимую с существующим lock-файлом
|
||||
RUN pnpm install
|
||||
|
||||
# Установка системных зависимостей для 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/*
|
||||
|
||||
# Сборка всех компонентов
|
||||
# Сборка всех пакетов через lerna-граф (зависимости собираются в
|
||||
# правильном порядке). Никаких --filter — пусть собирается всё, чтобы
|
||||
# образ годился любому потребителю mono-base.
|
||||
RUN lerna run build
|
||||
|
||||
# Финальный образ
|
||||
FROM builder AS runtime
|
||||
# Удалить 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
|
||||
|
||||
# Настройка переменных окружения
|
||||
# ── 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.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
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/*
|
||||
|
||||
# Python venv с WeasyPrint, готовый к использованию.
|
||||
COPY --from=builder /venv /venv
|
||||
ENV PATH="/venv/bin:$PATH"
|
||||
|
||||
# Проверка WeasyPrint
|
||||
RUN weasyprint --version
|
||||
# Приложение целиком (с уже урезанными до prod node_modules).
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Глобальные pnpm/lerna — нужны чтобы потребители mono-base могли
|
||||
# делать `CMD ["pnpm","-F","<pkg>","run","start"]` в production.
|
||||
RUN npm install -g pnpm lerna --no-fund --no-audit
|
||||
|
||||
# Sanity-check: WeasyPrint работает и виден через PATH.
|
||||
RUN weasyprint --version
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -41,6 +41,7 @@ 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 { writeWorkspaceIndexMarkdown } from '../sync/workspace-index.js'
|
||||
import { error, formatThrownValue, info, success, warn } from '../ui/output.js'
|
||||
|
||||
function startDir(): string {
|
||||
@@ -372,6 +373,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
return
|
||||
}
|
||||
const restoredRel = await runRestore(ctx, pathOrId)
|
||||
await writeWorkspaceIndexMarkdown(root)
|
||||
success(`Восстановлено с сервера: ${restoredRel}`)
|
||||
})
|
||||
|
||||
@@ -419,6 +421,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
return
|
||||
}
|
||||
await runClean(root)
|
||||
await writeWorkspaceIndexMarkdown(root)
|
||||
success('Индекс и staging очищены, каталоги проектов удалены.')
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ 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'
|
||||
|
||||
@@ -220,7 +222,9 @@ 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,6 +21,8 @@ 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'
|
||||
@@ -67,7 +69,7 @@ async function pickStoryFileRelativePath(
|
||||
const slug = generateSlug(title) || 'requirement'
|
||||
const id2 = storyRequirementIdPrefix2(storyRecordId, storyHash)
|
||||
const dir = issueArg?.titleSlug
|
||||
? `${basePath.replace(/\\/g, '/')}/issues/${capitalIdPathPrefix(issueArg.id)}-${issueArg.titleSlug}-requirements`
|
||||
? `${basePath.replace(/\\/g, '/')}/issue-requirements/${capitalIdPathPrefix(issueArg.id)}-${issueArg.titleSlug}`
|
||||
: `${basePath.replace(/\\/g, '/')}/requirements`
|
||||
const alt = `${dir}/${id2}-${slug}-${storyHash.slice(0, 6)}.md`
|
||||
const absAlt = path.join(root, alt)
|
||||
@@ -195,7 +197,11 @@ 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 }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ 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'
|
||||
|
||||
@@ -104,10 +106,20 @@ export async function runDelete(
|
||||
}
|
||||
|
||||
let entityHash = entry?.entity_hash ?? pending?.entity_hash ?? ''
|
||||
if (!entityHash && fileContent !== null) {
|
||||
let parentProjectHash: string | undefined
|
||||
if (fileContent !== null) {
|
||||
try {
|
||||
const parsed = parseBlagoMarkdown(fileContent)
|
||||
entityHash = String(parsed.data.hash ?? '').trim()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
@@ -146,7 +158,27 @@ export async function runDelete(
|
||||
const nextEntries = index.entries.filter(
|
||||
e => !(e.entity_type === entry.entity_type && e.entity_hash === entry.entity_hash),
|
||||
)
|
||||
await saveIndex(root, { entries: nextEntries })
|
||||
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)
|
||||
}
|
||||
|
||||
if (isStaged) {
|
||||
@@ -163,6 +195,8 @@ 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'
|
||||
|
||||
/** Всегда: служебные каталоги + маркер «текущий проект» (не документ Capital). */
|
||||
const DEFAULT_IGNORE = ['.blago/', '.git/', 'current_project.md']
|
||||
/** Всегда: служебные каталоги + маркер «текущий проект» + автогенерируемый INDEX.md (см. workspace-index). */
|
||||
const DEFAULT_IGNORE = ['.blago/', '.git/', 'current_project.md', 'INDEX.md']
|
||||
|
||||
/** Каталоги BMAD: не документы Capital — не заходим при рекурсии add/remove и не индексируем по любому пути. */
|
||||
const SYNC_SKIP_DIR_SEGMENTS = new Set(['_bmad', '_bmad_output', '_bmad-output'])
|
||||
|
||||
@@ -118,7 +118,7 @@ export function storyFileRelativePath(
|
||||
const id2 = storyRequirementIdPrefix2(storyRecordId, storyHash)
|
||||
if (issue?.titleSlug) {
|
||||
const ip = capitalIdPathPrefix(issue.id)
|
||||
return `${basePath}/issues/${ip}-${issue.titleSlug}-requirements/${id2}-${slug}.md`
|
||||
return `${basePath}/issue-requirements/${ip}-${issue.titleSlug}/${id2}-${slug}.md`
|
||||
}
|
||||
return `${basePath}/requirements/${id2}-${slug}.md`
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
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
|
||||
@@ -344,6 +345,7 @@ 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(
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
pushCreateIssue,
|
||||
pushCreateStory,
|
||||
} from './push-create.js'
|
||||
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
|
||||
|
||||
// Ответы GraphQL/Zeus часто дают _updated_at как unknown — сужаем безопасно.
|
||||
function toIso(v: unknown): string {
|
||||
@@ -289,4 +290,5 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
await saveStaging(ctx.root, { paths: [...remaining] })
|
||||
await writeWorkspaceIndexMarkdown(ctx.root)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// После 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,6 +44,7 @@ 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
|
||||
@@ -512,5 +513,6 @@ export async function restoreAllFromServer(ctx: AuthenticatedContext): Promise<R
|
||||
failures.push({ relativePath: rel, message: formatThrownValue(caught) })
|
||||
}
|
||||
}
|
||||
await writeWorkspaceIndexMarkdown(ctx.root)
|
||||
return { restored, failures }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,28 @@ 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')
|
||||
@@ -78,6 +100,7 @@ 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')
|
||||
@@ -99,6 +122,7 @@ 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, {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// 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')
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Образ-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 . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile --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,6 +51,77 @@ 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` в корне проекта):
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# 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,12 +1,41 @@
|
||||
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,14 +1,20 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-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",
|
||||
@@ -17,6 +23,7 @@
|
||||
"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",
|
||||
"create-coop": "esno src/index.ts create-coop",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
docker run --rm -it --name cdt \
|
||||
--volume $(pwd)/:/project \
|
||||
-w /project \
|
||||
dicoop/blockchain_v5.1.1:dev /bin/bash
|
||||
dicoop/blockchain:latest /bin/bash
|
||||
|
||||
@@ -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:v5.1.0-dev \
|
||||
dicoop/blockchain:latest \
|
||||
/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
|
||||
|
||||
@@ -229,7 +229,7 @@ export default class Blockchain {
|
||||
// console.log(data)
|
||||
// console.log("abi: ", serializedAbiHexString)
|
||||
|
||||
this.api.transact(
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
@@ -267,7 +267,24 @@ export default class Blockchain {
|
||||
console.log('contract setted: ', contract.target)
|
||||
}
|
||||
catch (e) {
|
||||
console.log(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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,105 +4,128 @@ 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.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.boot'),
|
||||
path: path.join(systemBase, 'eosio.boot'),
|
||||
target: 'eosio',
|
||||
},
|
||||
{
|
||||
name: 'eosio.system',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.system'),
|
||||
path: path.join(systemBase, 'eosio.system'),
|
||||
target: 'eosio',
|
||||
},
|
||||
{
|
||||
name: 'eosio.token',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.token'),
|
||||
path: path.join(systemBase, 'eosio.token'),
|
||||
target: 'eosio.token',
|
||||
},
|
||||
{
|
||||
name: 'eosio.msig',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.msig'),
|
||||
path: path.join(systemBase, 'eosio.msig'),
|
||||
target: 'eosio.msig',
|
||||
},
|
||||
{
|
||||
name: 'eosio.wrap',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/system/contracts/eosio.wrap'),
|
||||
path: path.join(systemBase, 'eosio.wrap'),
|
||||
target: 'eosio.wrap',
|
||||
},
|
||||
{
|
||||
name: 'registrator',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/registrator'),
|
||||
path: path.join(userBase, 'registrator'),
|
||||
target: 'registrator',
|
||||
},
|
||||
{
|
||||
name: 'soviet',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/soviet'),
|
||||
path: path.join(userBase, 'soviet'),
|
||||
target: 'soviet',
|
||||
},
|
||||
{
|
||||
name: 'marketplace',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/marketplace'),
|
||||
path: path.join(userBase, 'marketplace'),
|
||||
target: 'marketplace',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/draft'),
|
||||
path: path.join(userBase, 'draft'),
|
||||
target: 'draft',
|
||||
},
|
||||
{
|
||||
name: 'branch',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/branch'),
|
||||
path: path.join(userBase, 'branch'),
|
||||
target: 'branch',
|
||||
},
|
||||
{
|
||||
name: 'gateway',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/gateway'),
|
||||
path: path.join(userBase, 'gateway'),
|
||||
target: 'gateway',
|
||||
},
|
||||
{
|
||||
name: 'fund',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/fund'),
|
||||
path: path.join(userBase, 'fund'),
|
||||
target: 'fund',
|
||||
},
|
||||
{
|
||||
name: 'ledger',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/ledger'),
|
||||
path: path.join(userBase, 'ledger'),
|
||||
target: 'ledger',
|
||||
},
|
||||
{
|
||||
name: 'ledger2',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/ledger2'),
|
||||
path: path.join(userBase, 'ledger2'),
|
||||
target: 'ledger2',
|
||||
},
|
||||
{
|
||||
name: 'test',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/test'),
|
||||
path: path.join(userBase, 'test'),
|
||||
target: 'test',
|
||||
},
|
||||
{
|
||||
name: 'contributor',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/contributor'),
|
||||
path: path.join(userBase, 'contributor'),
|
||||
target: 'contributor',
|
||||
},
|
||||
{
|
||||
name: 'capital',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/capital'),
|
||||
path: path.join(userBase, 'capital'),
|
||||
target: 'capital',
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/wallet'),
|
||||
path: path.join(userBase, 'wallet'),
|
||||
target: 'wallet',
|
||||
},
|
||||
{
|
||||
name: 'loan',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/loan'),
|
||||
path: path.join(userBase, 'loan'),
|
||||
target: 'loan',
|
||||
},
|
||||
{
|
||||
name: 'meet',
|
||||
path: path.resolve(process.cwd(), '../contracts/build/contracts/meet'),
|
||||
path: path.join(userBase, 'meet'),
|
||||
target: 'meet',
|
||||
},
|
||||
{
|
||||
name: 'apps',
|
||||
path: path.join(userBase, 'apps'),
|
||||
target: 'apps',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -150,6 +150,10 @@ export default {
|
||||
name: 'contributor',
|
||||
code_permissions_to: ['contributor'],
|
||||
},
|
||||
{
|
||||
name: 'apps',
|
||||
code_permissions_to: ['apps'],
|
||||
},
|
||||
// {
|
||||
// name: provider_chairman,
|
||||
// },
|
||||
|
||||
@@ -304,6 +304,82 @@ 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,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,5 +2,5 @@ docker run --rm -it --name cleos \
|
||||
--volume ./eosio-wallet:/root/eosio-wallet \
|
||||
--volume ./scripts:/root/scripts \
|
||||
-w /root/scripts \
|
||||
dicoop/blockchain_v5.1.1:dev /bin/bash
|
||||
dicoop/blockchain:latest /bin/bash
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ add_contract_build(soviet)
|
||||
add_contract_build(registrator)
|
||||
add_contract_build(gateway)
|
||||
add_contract_build(marketplace)
|
||||
add_contract_build(apps)
|
||||
add_contract_build(system)
|
||||
|
||||
# Опции для тестов
|
||||
|
||||
@@ -11,5 +11,5 @@ fi
|
||||
docker run --rm --name cdt \
|
||||
--volume "$(pwd):/project" \
|
||||
-w /project \
|
||||
dicoop/blockchain_v5.1.1:dev \
|
||||
dicoop/blockchain:latest \
|
||||
/bin/bash -c "mkdir -p build && cd build && cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
|
||||
|
||||
@@ -18,6 +18,6 @@ fi
|
||||
docker run --rm --name cdt \
|
||||
--volume "$(pwd)/:/project" \
|
||||
-w /project/build \
|
||||
dicoop/blockchain_v5.1.1:dev \
|
||||
dicoop/blockchain:latest \
|
||||
/bin/bash -c "cmake -DBUILD_TARGET='$contract' -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
|
||||
# Проект
|
||||
project(apps)
|
||||
|
||||
find_package(cdt)
|
||||
|
||||
# Добавление контракта
|
||||
add_contract(apps apps apps.cpp)
|
||||
if(IS_TESTNET)
|
||||
target_compile_definitions(apps PUBLIC IS_TESTNET=1)
|
||||
else()
|
||||
# Не определяем IS_TESTNET для продакшн режима
|
||||
endif()
|
||||
@@ -0,0 +1,75 @@
|
||||
# Контракт `apps` — каталог приложений
|
||||
|
||||
Координационная плоскость платформы Каталога приложений ВОСХОД
|
||||
(`apps-catalog`). Деплоится на корневой KE-цепи и обслуживает несколько
|
||||
подсетей кооперативов одновременно.
|
||||
|
||||
## Что хранит
|
||||
|
||||
| Таблица | PK | Назначение |
|
||||
| ---------- | ----------------- | ----------------------------------------------------------- |
|
||||
| `packages` | `package_id` | Реестр зарегистрированных пакетов (Antelope `name`) |
|
||||
| `releases` | auto-inc | Текущие active + недавние superseded релизы (TTL 90 дней) |
|
||||
| `subs` | auto-inc | Подписки `(coopname × package_id × chain_id)` на пакеты |
|
||||
| `coops` | `coopname` | Кооперативы каталога: chain_id подсети + subnet-signing-key |
|
||||
|
||||
## Жизненный цикл релиза
|
||||
|
||||
```
|
||||
regpackage(pkg, owner, subnets)
|
||||
│
|
||||
▼
|
||||
┌─────────┐ setrelease(scope=all) ┌────────────┐
|
||||
│ packages│ ─────────────────────────────► │ releases │
|
||||
│ pkg │ (creates active, supersedes │ active │
|
||||
└─────────┘ prior active с тем же scope) └────────────┘
|
||||
│
|
||||
setrelease(новая) │
|
||||
▼
|
||||
┌────────────┐
|
||||
│ superseded │ TTL 90d
|
||||
│ + auto-rm │ inline cleanup
|
||||
└────────────┘
|
||||
│
|
||||
│ reactivate(version=старая)
|
||||
▼
|
||||
┌────────────┐
|
||||
│ active │
|
||||
└────────────┘
|
||||
|
||||
withdraw(version) ┌────────────┐
|
||||
─────────────────────────►│ withdrawn │ не подпадает под TTL
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
## Authorization (MVP)
|
||||
|
||||
- Все mutating actions: `<coopname>@active`.
|
||||
- `cleanup` — без auth (доброкачественная операция).
|
||||
- `migrate` — `apps@active`.
|
||||
|
||||
## Authorization (план)
|
||||
|
||||
- `regsub`/`expsub` → выделенный `<coopname>@billing`-permission, когда
|
||||
биллинг отделится от провайдера каталога.
|
||||
- `regcoop`/`setcoop` → `<coopname>@subnet`-permission, когда onboarding
|
||||
кооперативов формализуется отдельным процессом.
|
||||
|
||||
Миграция через `eosio.msig`-предложение.
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
```bash
|
||||
# из components/contracts/
|
||||
./build.sh apps prod # production
|
||||
./build.sh apps test # с #define IS_TESTNET=1
|
||||
```
|
||||
|
||||
Артефакт — `build/contracts/apps/apps.{wasm,abi}`. Упаковка артефактов
|
||||
всех контрактов в docker-образ — см. `components/contracts/docker/`.
|
||||
|
||||
## Якоря
|
||||
|
||||
- `apps-catalog` epics.md Story 6.x — KE bootstrap.
|
||||
- `apps-catalog` docs/architecture.md §"KE blockchain (контракт apps)".
|
||||
- Memory `project_subnet_identification` — chain_id как идентификатор подсети.
|
||||
@@ -0,0 +1,24 @@
|
||||
#include "apps.hpp"
|
||||
#include <eosio/transaction.hpp>
|
||||
|
||||
#include "src/regpackage.cpp"
|
||||
#include "src/transferpkg.cpp"
|
||||
#include "src/setrelease.cpp"
|
||||
#include "src/reactivate.cpp"
|
||||
#include "src/withdraw.cpp"
|
||||
#include "src/cleanup.cpp"
|
||||
#include "src/regsub.cpp"
|
||||
#include "src/expsub.cpp"
|
||||
#include "src/regcoop.cpp"
|
||||
#include "src/setcoop.cpp"
|
||||
|
||||
/**
|
||||
* \brief Миграция контракта.
|
||||
* \details Пустышка для CDT-апгрейдов. Реальные миграции состояния
|
||||
* выполняются через `eosio.msig`-предложение с конкретным
|
||||
* планом (drop таблиц, переезд данных).
|
||||
* \note Авторизация: @ apps @ active.
|
||||
*/
|
||||
[[eosio::action]] void apps::migrate() {
|
||||
require_auth(_apps);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/contract.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/multi_index.hpp>
|
||||
#include <eosio/system.hpp>
|
||||
#include <eosio/time.hpp>
|
||||
#include <optional>
|
||||
|
||||
#include "../lib/index.hpp"
|
||||
|
||||
using namespace Apps;
|
||||
|
||||
/**
|
||||
\defgroup public_apps Контракт APPS
|
||||
|
||||
* Смарт-контракт каталога приложений `apps` — координационная плоскость
|
||||
* для платформы Каталога приложений ВОСХОД (`apps-catalog`). Контракт
|
||||
* деплоится на корневой KE-цепи и обслуживает несколько подсетей
|
||||
* (одна установка `CA-auth` → N подсетей кооперативов).
|
||||
*
|
||||
* Что хранит:
|
||||
* - `packages` — реестр зарегистрированных пакетов (PK = `name`).
|
||||
* - `releases` — текущие active + недавние superseded релизы (TTL 90 дней).
|
||||
* - `subs` — подписки кооперативов на пакеты (`coopname × package_id × chain_id`).
|
||||
* - `coops` — кооперативы, подключённые к каталогу, со своими
|
||||
* subnet-signing-key и chain_id подсети.
|
||||
*
|
||||
* Чего контракт НЕ делает (намеренно):
|
||||
* - Не проверяет ECDSA-подписи `signed-request`'ов от DC — это off-chain
|
||||
* в `CA-auth`, который читает `coops.signing_key` через RPC.
|
||||
* - Не выпускает JWT — это HS256 в `CA-auth`.
|
||||
* - Не валидирует tarball — `tarball_sha256` хранится для аудита.
|
||||
* - Не считает биллинг — биллинг живёт в кабинете ВОСХОД и `pay.coopenomics`,
|
||||
* сюда приходит уже факт «подписка активна».
|
||||
* - Не пишет audit-log — Antelope сам пишет в trace; `CA` ведёт свой
|
||||
* `audit_log_admin` для off-chain событий.
|
||||
*
|
||||
* Authorization model (MVP, упрощённая):
|
||||
* - Все mutating actions требуют `<coopname>@active`.
|
||||
* - Разделение прав на `@billing`, `@subnet-operator` и т.п. — TODO,
|
||||
* введём через `eosio.msig` миграцию когда биллинг отделится от
|
||||
* провайдера каталога.
|
||||
* - `cleanup` — без auth: операция идемпотентна и удаляет только
|
||||
* TTL-просроченные `superseded` записи.
|
||||
*
|
||||
* \see /home/admin/apps-catalog/docs/architecture.md
|
||||
* \see epics.md Story 6.x (KE bootstrap)
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_apps_processes Процессы
|
||||
\ingroup public_apps
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_apps_actions Действия
|
||||
\ingroup public_apps
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_apps_tables Таблицы
|
||||
\ingroup public_apps
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_apps_consts Константы
|
||||
\ingroup public_apps
|
||||
*/
|
||||
|
||||
namespace Apps {
|
||||
/**
|
||||
* Окно retention для `superseded`-релизов: 90 дней. Записи старше
|
||||
* этого окна удаляются inline в `setrelease` или явным `cleanup`.
|
||||
* `withdrawn` под TTL не подпадают — отзыв делается сознательно
|
||||
* и должен быть видим до явного purge.
|
||||
*/
|
||||
static constexpr uint64_t RELEASE_RETENTION_SECS = 90 * 86400;
|
||||
|
||||
/**
|
||||
* Бюджет cleanup'а внутри одного вызова `setrelease`: не удалять
|
||||
* больше N записей за раз, чтобы не упереться в CPU-limit транзакции.
|
||||
* Остаток подметёт следующий `setrelease` или явный `cleanup(package_id)`.
|
||||
*/
|
||||
static constexpr uint64_t CLEANUP_BUDGET_PER_CALL = 50;
|
||||
} // namespace Apps
|
||||
|
||||
class [[eosio::contract(APPS)]] apps : public eosio::contract {
|
||||
|
||||
public:
|
||||
apps(eosio::name receiver, eosio::name code,
|
||||
eosio::datastream<const char *> ds)
|
||||
: eosio::contract(receiver, code, ds) {}
|
||||
|
||||
[[eosio::action]] void migrate();
|
||||
|
||||
// ─── packages ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Зарегистрировать новый пакет в каталоге.
|
||||
* \param coopname провайдер каталога (тот, кто подписал транзакцию).
|
||||
* \param package_id кодовое имя пакета (Antelope name, ≤12 chars).
|
||||
* \param package_name внешнее имя (npm/go/oci) — может содержать `@scope/name`.
|
||||
* \param owner username владельца пакета.
|
||||
* \param compatible_subnets human-labels подсетей, в которых пакет совместим.
|
||||
* \note Авторизация: @p coopname @ active. Идемпотентность: фейл, если
|
||||
* package_id уже зарегистрирован (нужен явный transferpkg/withdraw
|
||||
* перед re-регистрацией).
|
||||
*/
|
||||
[[eosio::action]] void regpackage(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string package_name,
|
||||
eosio::name owner,
|
||||
std::vector<eosio::name> compatible_subnets);
|
||||
|
||||
/**
|
||||
* \brief Передать владение пакетом другому пользователю (FR3).
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void transferpkg(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
eosio::name new_owner);
|
||||
|
||||
// ─── releases ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Опубликовать новый release пакета.
|
||||
*
|
||||
* Атомарно (FR8 «approve → release.ACTIVE»):
|
||||
* 1. Создаёт row в `releases` со `status=active`, `superseded_at=0`.
|
||||
* 2. Любой существующий `active` с тем же `(package_id, scope)` →
|
||||
* `superseded`, `superseded_at=now`.
|
||||
* 3. Если `scope.kind == "all"` — обновляет `packages.last_active_version`.
|
||||
* 4. Inline-cleanup: удаляет до `CLEANUP_BUDGET_PER_CALL` самых старых
|
||||
* `superseded`-записей с `superseded_at < now - RELEASE_RETENTION_SECS`.
|
||||
*
|
||||
* \param scope область видимости (см. `scope_t`).
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void setrelease(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version,
|
||||
Apps::scope_t scope,
|
||||
eosio::checksum256 tarball_sha256,
|
||||
eosio::name moderated_by,
|
||||
std::string meta);
|
||||
|
||||
/**
|
||||
* \brief Реактивировать предыдущую версию пакета (FR43).
|
||||
* \details Находит release по `(package_id, version)`; ставит ему
|
||||
* `status=active`, `superseded_at=0`. Текущий `active` с тем же
|
||||
* scope → `superseded`. Если scope=all — обновляет
|
||||
* `packages.last_active_version`.
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void reactivate(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version);
|
||||
|
||||
/**
|
||||
* \brief Отозвать релиз (CVE / нарушение / юридический отзыв).
|
||||
* \details Ставит `status=withdrawn`. Если это был `active` со scope=all —
|
||||
* `packages.last_active_version` обнуляется ("") до следующего
|
||||
* setrelease/reactivate.
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void withdraw(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version,
|
||||
std::string reason);
|
||||
|
||||
/**
|
||||
* \brief Ручной cleanup TTL-просроченных `superseded`-записей пакета.
|
||||
* \details Удаляет до `CLEANUP_BUDGET_PER_CALL` записей с
|
||||
* `superseded_at < now - RELEASE_RETENTION_SECS`. Идемпотентен.
|
||||
* \note Без авторизации: операция доброкачественная, удаляет только
|
||||
* уже невидимые в продакшене записи. Любой может вызвать
|
||||
* (и заплатить за CPU).
|
||||
*/
|
||||
[[eosio::action]] void cleanup(eosio::name package_id);
|
||||
|
||||
// ─── subscriptions ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Зарегистрировать или продлить подписку кооператива на пакет.
|
||||
* \details Idempotent по (coopname, package_id):
|
||||
* - row не существует → создаём с `active=true`.
|
||||
* - row существует → обновляем `plan`, `end_at` = max(текущее, новое),
|
||||
* `active=true`, `chain_id` (на случай переноса
|
||||
* между подсетями).
|
||||
* \param coopname провайдер каталога (тот, кто выписывает подписку).
|
||||
* \param subscriber кооператив-подписчик.
|
||||
* \param chain_id подсеть, в которой действует подписка.
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void regsub(eosio::name coopname,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id,
|
||||
eosio::checksum256 chain_id,
|
||||
eosio::name plan,
|
||||
eosio::time_point_sec start_at,
|
||||
eosio::time_point_sec end_at);
|
||||
|
||||
/**
|
||||
* \brief Деактивировать подписку (`active=false`); row не удаляется
|
||||
* (нужен для аудита истечения).
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void expsub(eosio::name coopname,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id);
|
||||
|
||||
// ─── coops ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Зарегистрировать кооператив в каталоге.
|
||||
* \details Этот action — часть процесса подключения коопа к каталогу
|
||||
* приложений на Восходе (вне scope текущего MVP). Кооператив
|
||||
* сам подписывает транзакцию (`require_auth(coopname)`),
|
||||
* подтверждая, что владеет приватной частью `signing_key`.
|
||||
* \param coopname кооператив, который регистрируется.
|
||||
* \param chain_id подсеть, к которой принадлежит коопе.
|
||||
* \param subnet_label human-label подсети ("ru","by",...).
|
||||
* \param signing_key subnet-signing-key (отдельный от @ active).
|
||||
* \note Авторизация: @p coopname @ active. Идемпотентность: фейл, если
|
||||
* уже зарегистрирован — для обновления использовать `setcoop`.
|
||||
*/
|
||||
[[eosio::action]] void regcoop(eosio::name coopname,
|
||||
eosio::checksum256 chain_id,
|
||||
eosio::name subnet_label,
|
||||
eosio::public_key signing_key);
|
||||
|
||||
/**
|
||||
* \brief Обновить параметры кооператива (ротация ключа, перенос подсети,
|
||||
* деактивация).
|
||||
* \details Все параметры опциональны — обновляются только переданные.
|
||||
* При смене `signing_key` обновляется `key_rotated_at=now`.
|
||||
* Ранее выпущенные JWT (TTL 14 дней) остаются валидны;
|
||||
* новые signed-request'ы должны идти от нового приват-ключа.
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void setcoop(eosio::name coopname,
|
||||
std::optional<eosio::checksum256> chain_id,
|
||||
std::optional<eosio::name> subnet_label,
|
||||
std::optional<eosio::public_key> signing_key,
|
||||
std::optional<bool> active);
|
||||
|
||||
// ─── service tables ─────────────────────────────────────────────────
|
||||
|
||||
struct [[eosio::table, eosio::contract(APPS)]] counts : counts_base {};
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* \brief Ручной cleanup TTL-просроченных `superseded`-записей пакета.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Используется когда `setrelease` для пакета давно не вызывался, и
|
||||
* inline-cleanup в нём не подметает хвост. Удаляет до
|
||||
* `CLEANUP_BUDGET_PER_CALL` записей с `status=superseded` и
|
||||
* `superseded_at < now - RELEASE_RETENTION_SECS`.
|
||||
*
|
||||
* Идемпотентно. Многократные вызовы безопасны: каждый вызов чистит свой
|
||||
* batch до budget'а.
|
||||
*
|
||||
* \note БЕЗ авторизации: операция доброкачественная, удаляет только
|
||||
* уже невидимые в продакшене записи, не меняет логику. Любой
|
||||
* может вызвать (и заплатить за CPU). Это снижает оперативную
|
||||
* нагрузку на оператора каталога.
|
||||
*/
|
||||
void apps::cleanup(eosio::name package_id) {
|
||||
releases_index releases(_apps, _apps.value);
|
||||
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
|
||||
|
||||
auto now_sec = eosio::current_time_point().sec_since_epoch();
|
||||
uint64_t threshold = now_sec > Apps::RELEASE_RETENTION_SECS
|
||||
? now_sec - Apps::RELEASE_RETENTION_SECS
|
||||
: 0;
|
||||
|
||||
uint128_t key_super = ((uint128_t)package_id.value << 64) | "superseded"_n.value;
|
||||
uint64_t removed = 0;
|
||||
|
||||
auto it = by_pkgstat.lower_bound(key_super);
|
||||
while (it != by_pkgstat.end()
|
||||
&& ((uint128_t)it->package_id.value << 64 | it->status.value) == key_super
|
||||
&& removed < Apps::CLEANUP_BUDGET_PER_CALL) {
|
||||
if (it->superseded_at.sec_since_epoch() < threshold) {
|
||||
it = by_pkgstat.erase(it);
|
||||
++removed;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* \brief Деактивировать подписку.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Ставит `active=false`. Запись остаётся в таблице — она нужна для
|
||||
* аудита истечения и для повторной активации (если кооператив снова
|
||||
* подпишется, пойдёт через тот же row через `regsub`-upsert).
|
||||
*
|
||||
* Cleanup физически просроченных записей (например, истёкших год
|
||||
* назад и более не нужных в RAM) — задача отдельного периодического
|
||||
* действия. Вне scope MVP.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::expsub(eosio::name coopname,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id) {
|
||||
require_auth(coopname);
|
||||
|
||||
subs_index subs(_apps, _apps.value);
|
||||
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
|
||||
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
|
||||
auto sub_it = by_cooppkg.find(key);
|
||||
eosio::check(sub_it != by_cooppkg.end(), "Подписка не найдена");
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
by_cooppkg.modify(sub_it, coopname, [&](auto &s) {
|
||||
s.active = false;
|
||||
s.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* \brief Реактивировать предыдущую версию пакета (FR43).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Используется для отката broken release без full re-publish:
|
||||
* - Находит существующий release `(package_id, version)` со статусом
|
||||
* `superseded` или `withdrawn`.
|
||||
* - Текущий active с тем же `scope` → `superseded`.
|
||||
* - Найденный release → `active`, `superseded_at=0`.
|
||||
* - Если scope=all — обновляется `packages.last_active_version`.
|
||||
*
|
||||
* Окно реактивации ограничено TTL retention (90 дней): за пределами окна
|
||||
* superseded-записи уже удалены, и реактивация не возможна — нужен
|
||||
* полноценный re-publish.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::reactivate(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version) {
|
||||
require_auth(coopname);
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto pkg_it = packages.find(package_id.value);
|
||||
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
|
||||
|
||||
releases_index releases(_apps, _apps.value);
|
||||
auto by_pkg = releases.get_index<"bypackage"_n>();
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
|
||||
// Найти release под реактивацию.
|
||||
auto target = by_pkg.end();
|
||||
for (auto rit = by_pkg.lower_bound(package_id.value);
|
||||
rit != by_pkg.end() && rit->package_id == package_id;
|
||||
++rit) {
|
||||
if (rit->version == version) {
|
||||
target = rit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
eosio::check(target != by_pkg.end(),
|
||||
"Релиз для реактивации не найден — возможно вышел за TTL");
|
||||
eosio::check(target->status != "active"_n,
|
||||
"Релиз уже active");
|
||||
|
||||
Apps::scope_t target_scope = target->scope;
|
||||
|
||||
// Supersede current active с тем же scope.
|
||||
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
|
||||
uint128_t key_active = ((uint128_t)package_id.value << 64) | "active"_n.value;
|
||||
auto act_it = by_pkgstat.lower_bound(key_active);
|
||||
while (act_it != by_pkgstat.end()
|
||||
&& ((uint128_t)act_it->package_id.value << 64 | act_it->status.value) == key_active) {
|
||||
if (act_it->scope.kind == target_scope.kind && act_it->scope.targets == target_scope.targets) {
|
||||
auto stash = act_it;
|
||||
++act_it;
|
||||
releases.modify(*stash, coopname, [&](auto &r) {
|
||||
r.status = "superseded"_n;
|
||||
r.superseded_at = now_tps;
|
||||
});
|
||||
} else {
|
||||
++act_it;
|
||||
}
|
||||
}
|
||||
|
||||
// Поднять target в active.
|
||||
by_pkg.modify(target, coopname, [&](auto &r) {
|
||||
r.status = "active"_n;
|
||||
r.superseded_at = eosio::time_point_sec(0);
|
||||
});
|
||||
|
||||
if (target_scope.kind == "all"_n) {
|
||||
packages.modify(pkg_it, coopname, [&](auto &p) {
|
||||
p.last_active_version = version;
|
||||
p.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* \brief Регистрация кооператива в каталоге.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Кооператив сам подписывает транзакцию своим `@active`-ключом и
|
||||
* передаёт публичную часть `signing_key` (отдельный ключ для подписи
|
||||
* signed-request'ов от DC-оркестратора в `CA-auth`).
|
||||
*
|
||||
* Идемпотентность: если такой `coopname` уже зарегистрирован — фейл.
|
||||
* Для обновления полей — `setcoop`.
|
||||
*
|
||||
* \note Контекст: этот action — часть процесса подключения кооператива
|
||||
* к каталогу приложений на Восходе (вне scope текущего MVP
|
||||
* apps-catalog). Реальный flow: кабинет ВОСХОД проводит
|
||||
* onboarding, генерирует/принимает signing keypair, подписывает
|
||||
* и отправляет эту транзакцию.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::regcoop(eosio::name coopname,
|
||||
eosio::checksum256 chain_id,
|
||||
eosio::name subnet_label,
|
||||
eosio::public_key signing_key) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(subnet_label.value != 0, "subnet_label обязателен");
|
||||
|
||||
coops_index coops(_apps, _apps.value);
|
||||
auto it = coops.find(coopname.value);
|
||||
eosio::check(it == coops.end(), "Кооператив уже зарегистрирован — используйте setcoop");
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
coops.emplace(coopname, [&](auto &c) {
|
||||
c.coopname = coopname;
|
||||
c.chain_id = chain_id;
|
||||
c.subnet_label = subnet_label;
|
||||
c.signing_key = signing_key;
|
||||
c.active = true;
|
||||
c.registered_at = now_tps;
|
||||
c.key_rotated_at = now_tps;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* \brief Регистрация нового пакета в каталоге.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Контракт операции:
|
||||
* - PK = `package_id`. Если такой уже существует — фейлим («имя занято»).
|
||||
* - `package_name` — внешнее имя; не уникально (теоретически два пакета
|
||||
* могут иметь одинаковое npm-имя, но разные `package_id`; на практике
|
||||
* это ловится UI/правилами кабинета ВОСХОД).
|
||||
* - `compatible_subnets` — может быть пустой; в этом случае пакет
|
||||
* считается совместимым со всеми подсетями (resolver на стороне `CA`
|
||||
* обрабатывает пустой набор как «нет ограничений»).
|
||||
* - `last_active_version` инициализируется пустой строкой; заполнится
|
||||
* первым `setrelease` со scope=all.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::regpackage(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string package_name,
|
||||
eosio::name owner,
|
||||
std::vector<eosio::name> compatible_subnets) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(package_id.value != 0, "package_id не может быть пустым");
|
||||
eosio::check(owner.value != 0, "owner не может быть пустым");
|
||||
eosio::check(!package_name.empty(), "package_name не может быть пустым");
|
||||
eosio::check(package_name.size() <= 256, "package_name слишком длинный (макс 256)");
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto it = packages.find(package_id.value);
|
||||
eosio::check(it == packages.end(), "Пакет с таким package_id уже зарегистрирован");
|
||||
|
||||
auto now = eosio::current_time_point().sec_since_epoch();
|
||||
packages.emplace(coopname, [&](auto &p) {
|
||||
p.package_id = package_id;
|
||||
p.package_name = package_name;
|
||||
p.owner = owner;
|
||||
p.compatible_subnets = compatible_subnets;
|
||||
p.last_active_version = "";
|
||||
p.created_at = eosio::time_point_sec(now);
|
||||
p.updated_at = eosio::time_point_sec(now);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* \brief Регистрация или продление подписки кооператива на пакет.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Idempotent по `(subscriber, package_id)`:
|
||||
* - Row нет → создаём с `active=true`, `start_at`, `end_at`, `plan`, `chain_id`.
|
||||
* - Row есть → обновляем `plan`, `end_at` = `max(текущее, новое)`,
|
||||
* `active=true`, `chain_id` (на случай миграции коопа
|
||||
* между подсетями), `start_at` оставляем как был
|
||||
* (это маркер «когда вообще началась подписка»).
|
||||
*
|
||||
* Это поведение специально сознательное: биллинг кабинета ВОСХОД при
|
||||
* retry / повторной отправке не должен бояться дубликатов. История
|
||||
* выписки подписок остаётся в blockchain trace'ах (Antelope action history),
|
||||
* а не в таблице `subs`.
|
||||
*
|
||||
* \param coopname провайдер каталога (тот, кто выписывает подписку).
|
||||
* \param subscriber кооператив-подписчик.
|
||||
* \param chain_id подсеть, в которой действует подписка.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active. Проверки `chain_id` против
|
||||
* `coops.chain_id` НЕ делаем, потому что:
|
||||
* 1) `coops` row может ещё не существовать при первой выдаче подписки;
|
||||
* 2) подписка теоретически может быть выдана коопу из другой подсети
|
||||
* (миграция). Лучше доверять биллингу.
|
||||
*/
|
||||
void apps::regsub(eosio::name coopname,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id,
|
||||
eosio::checksum256 chain_id,
|
||||
eosio::name plan,
|
||||
eosio::time_point_sec start_at,
|
||||
eosio::time_point_sec end_at) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(subscriber.value != 0, "subscriber не может быть пустым");
|
||||
eosio::check(plan.value != 0, "plan не может быть пустым");
|
||||
eosio::check(end_at >= start_at, "end_at должен быть >= start_at");
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto pkg_it = packages.find(package_id.value);
|
||||
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
|
||||
|
||||
subs_index subs(_apps, _apps.value);
|
||||
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
|
||||
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
|
||||
auto sub_it = by_cooppkg.find(key);
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
|
||||
if (sub_it == by_cooppkg.end()) {
|
||||
uint64_t new_id = get_global_id(_apps, "sub"_n);
|
||||
subs.emplace(coopname, [&](auto &s) {
|
||||
s.id = new_id;
|
||||
s.coopname = subscriber;
|
||||
s.package_id = package_id;
|
||||
s.chain_id = chain_id;
|
||||
s.plan = plan;
|
||||
s.active = true;
|
||||
s.start_at = start_at;
|
||||
s.end_at = end_at;
|
||||
s.created_at = now_tps;
|
||||
s.updated_at = now_tps;
|
||||
});
|
||||
} else {
|
||||
by_cooppkg.modify(sub_it, coopname, [&](auto &s) {
|
||||
s.chain_id = chain_id;
|
||||
s.plan = plan;
|
||||
s.active = true;
|
||||
s.end_at = end_at > s.end_at ? end_at : s.end_at;
|
||||
s.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* \brief Обновление параметров кооператива.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Все параметры опциональны — обновляются только переданные. Сценарии
|
||||
* использования:
|
||||
* - Ротация подписи: `setcoop(coopname, signing_key=<new>)`. Меняет
|
||||
* `signing_key` и `key_rotated_at=now`.
|
||||
* - Деактивация: `setcoop(coopname, active=false)`. CA-auth перестанет
|
||||
* отвечать на запросы от этого коопа.
|
||||
* - Миграция между подсетями: `setcoop(coopname, chain_id=<new>, subnet_label=<new>)`.
|
||||
* - Обновление human-label: `setcoop(coopname, subnet_label=<new>)`.
|
||||
*
|
||||
* \note Ранее выпущенные JWT (TTL 14 дней в CA-auth) остаются валидны
|
||||
* после ротации `signing_key`. Только новые signed-request'ы должны
|
||||
* идти от нового приват-ключа. Это даёт zero-downtime ротации.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active. Если `@active`-ключ
|
||||
* коопаккаунта скомпрометирован, эта операция не доступна злоумышленнику —
|
||||
* сначала кооператив должен ротировать `@active` через
|
||||
* `eosio::updateauth` (это отдельная операция, владелец-ключ).
|
||||
*/
|
||||
void apps::setcoop(eosio::name coopname,
|
||||
std::optional<eosio::checksum256> chain_id,
|
||||
std::optional<eosio::name> subnet_label,
|
||||
std::optional<eosio::public_key> signing_key,
|
||||
std::optional<bool> active) {
|
||||
require_auth(coopname);
|
||||
|
||||
coops_index coops(_apps, _apps.value);
|
||||
auto it = coops.find(coopname.value);
|
||||
eosio::check(it != coops.end(), "Кооператив не зарегистрирован — используйте regcoop");
|
||||
|
||||
bool nothing_to_change = !chain_id.has_value()
|
||||
&& !subnet_label.has_value()
|
||||
&& !signing_key.has_value()
|
||||
&& !active.has_value();
|
||||
eosio::check(!nothing_to_change, "Нет полей для обновления");
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
coops.modify(it, coopname, [&](auto &c) {
|
||||
if (chain_id.has_value()) c.chain_id = *chain_id;
|
||||
if (subnet_label.has_value()) {
|
||||
eosio::check(subnet_label->value != 0, "subnet_label не может быть пустым");
|
||||
c.subnet_label = *subnet_label;
|
||||
}
|
||||
if (signing_key.has_value()) {
|
||||
c.signing_key = *signing_key;
|
||||
c.key_rotated_at = now_tps;
|
||||
}
|
||||
if (active.has_value()) c.active = *active;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* \brief Опубликовать новый release пакета — атомарный transition.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Алгоритм (выполняется в одной транзакции):
|
||||
*
|
||||
* 1. Проверки: пакет существует; статус — известный; scope согласован
|
||||
* (kind=all → targets пустой; kind=subnet/canary → targets непустой).
|
||||
* 2. Выполнить supersede предыдущего active с тем же scope (точное
|
||||
* совпадение `kind` + `targets`). Записывается `superseded_at=now`.
|
||||
* Если кто-то уже superseded — пропускаем.
|
||||
* 3. Создать новый row со `status=active`, `superseded_at=0`.
|
||||
* 4. Если `scope.kind == "all"_n` — обновить `packages.last_active_version`.
|
||||
* 5. Inline-cleanup: пройтись по `bysuperseded`-индексу для этого
|
||||
* пакета и удалить до `CLEANUP_BUDGET_PER_CALL` записей с
|
||||
* `superseded_at < now - RELEASE_RETENTION_SECS`.
|
||||
*
|
||||
* Это ядро «atomic moderation approve» (FR8): индекс CA + KE.releases
|
||||
* — либо обе, либо ни одна. Со стороны blockchain'а — гарантия атомарности
|
||||
* в пределах одной транзакции.
|
||||
*
|
||||
* \note Re-publish одной и той же `version` запрещён: если releases
|
||||
* уже содержит row для (package_id, version) — фейл. Версии
|
||||
* публикуются монотонно вверх; для отката используется
|
||||
* `reactivate(version=старая)`.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::setrelease(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version,
|
||||
Apps::scope_t scope,
|
||||
eosio::checksum256 tarball_sha256,
|
||||
eosio::name moderated_by,
|
||||
std::string meta) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(!version.empty() && version.size() <= 64, "Некорректная version");
|
||||
eosio::check(scope.kind == "all"_n || scope.kind == "subnet"_n || scope.kind == "canary"_n,
|
||||
"scope.kind должен быть all|subnet|canary");
|
||||
if (scope.kind == "all"_n) {
|
||||
eosio::check(scope.targets.empty(), "Для scope=all targets должен быть пустым");
|
||||
} else {
|
||||
eosio::check(!scope.targets.empty(), "Для scope=subnet|canary targets обязателен");
|
||||
}
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto pkg_it = packages.find(package_id.value);
|
||||
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
|
||||
|
||||
releases_index releases(_apps, _apps.value);
|
||||
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
|
||||
|
||||
auto now_sec = eosio::current_time_point().sec_since_epoch();
|
||||
auto now_tps = eosio::time_point_sec(now_sec);
|
||||
|
||||
// Запретить дубликат version — пройтись по всем релизам пакета.
|
||||
auto by_pkg = releases.get_index<"bypackage"_n>();
|
||||
for (auto rit = by_pkg.lower_bound(package_id.value);
|
||||
rit != by_pkg.end() && rit->package_id == package_id;
|
||||
++rit) {
|
||||
eosio::check(rit->version != version,
|
||||
"Релиз с такой version уже опубликован — используйте reactivate");
|
||||
}
|
||||
|
||||
// Supersede существующих active с тем же scope.
|
||||
uint128_t key_active = ((uint128_t)package_id.value << 64) | "active"_n.value;
|
||||
auto act_it = by_pkgstat.lower_bound(key_active);
|
||||
while (act_it != by_pkgstat.end()
|
||||
&& ((uint128_t)act_it->package_id.value << 64 | act_it->status.value) == key_active) {
|
||||
if (act_it->scope.kind == scope.kind && act_it->scope.targets == scope.targets) {
|
||||
// Найден active с тем же scope → supersede.
|
||||
auto stash = act_it;
|
||||
++act_it;
|
||||
releases.modify(*stash, coopname, [&](auto &r) {
|
||||
r.status = "superseded"_n;
|
||||
r.superseded_at = now_tps;
|
||||
});
|
||||
} else {
|
||||
++act_it;
|
||||
}
|
||||
}
|
||||
|
||||
// Создать новый active.
|
||||
uint64_t new_id = get_global_id(_apps, "release"_n);
|
||||
releases.emplace(coopname, [&](auto &r) {
|
||||
r.id = new_id;
|
||||
r.package_id = package_id;
|
||||
r.version = version;
|
||||
r.scope = scope;
|
||||
r.status = "active"_n;
|
||||
r.published_at = now_tps;
|
||||
r.superseded_at = eosio::time_point_sec(0);
|
||||
r.moderated_by = moderated_by;
|
||||
r.tarball_sha256 = tarball_sha256;
|
||||
r.meta = meta;
|
||||
});
|
||||
|
||||
// Обновить last_active_version только для scope=all.
|
||||
if (scope.kind == "all"_n) {
|
||||
packages.modify(pkg_it, coopname, [&](auto &p) {
|
||||
p.last_active_version = version;
|
||||
p.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
|
||||
// Inline-cleanup TTL-просроченных superseded для этого пакета.
|
||||
uint128_t key_super = ((uint128_t)package_id.value << 64) | "superseded"_n.value;
|
||||
uint64_t threshold = now_sec > Apps::RELEASE_RETENTION_SECS
|
||||
? now_sec - Apps::RELEASE_RETENTION_SECS
|
||||
: 0;
|
||||
uint64_t removed = 0;
|
||||
auto sup_it = by_pkgstat.lower_bound(key_super);
|
||||
while (sup_it != by_pkgstat.end()
|
||||
&& ((uint128_t)sup_it->package_id.value << 64 | sup_it->status.value) == key_super
|
||||
&& removed < Apps::CLEANUP_BUDGET_PER_CALL) {
|
||||
if (sup_it->superseded_at.sec_since_epoch() < threshold) {
|
||||
sup_it = by_pkgstat.erase(sup_it);
|
||||
++removed;
|
||||
} else {
|
||||
++sup_it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* \brief Передача владения пакетом другому пользователю (FR3).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Меняет `owner` на `new_owner`. Прав на сами релизы это не меняет —
|
||||
* `setrelease` всё равно выполняется провайдером каталога (`coopname`),
|
||||
* `owner` — это поле для аудита и UI «чей это пакет».
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::transferpkg(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
eosio::name new_owner) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(new_owner.value != 0, "new_owner не может быть пустым");
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto it = packages.find(package_id.value);
|
||||
eosio::check(it != packages.end(), "Пакет не найден");
|
||||
eosio::check(it->owner != new_owner, "Пакет уже принадлежит этому владельцу");
|
||||
|
||||
auto now = eosio::current_time_point().sec_since_epoch();
|
||||
packages.modify(it, coopname, [&](auto &p) {
|
||||
p.owner = new_owner;
|
||||
p.updated_at = eosio::time_point_sec(now);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* \brief Отозвать релиз (CVE / нарушение / юридический отзыв).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Семантика отзыва:
|
||||
* - Релиз становится `withdrawn` и НЕ может быть установлен через CA-auth.
|
||||
* - Запись остаётся в таблице — TTL на withdrawn не действует.
|
||||
* - Если withdrawn-релиз был active со scope=all, `last_active_version`
|
||||
* обнуляется ("") — UI должен показать «нет активной версии», пока
|
||||
* оператор не выпустит новый setrelease или не реактивирует старую.
|
||||
* - Если был active со scope=subnet/canary, `last_active_version` не
|
||||
* трогается — она показывает версию для основной массы кооперативов.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::withdraw(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version,
|
||||
std::string reason) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(reason.size() <= 512, "reason слишком длинный (макс 512)");
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto pkg_it = packages.find(package_id.value);
|
||||
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
|
||||
|
||||
releases_index releases(_apps, _apps.value);
|
||||
auto by_pkg = releases.get_index<"bypackage"_n>();
|
||||
|
||||
auto target = by_pkg.end();
|
||||
for (auto rit = by_pkg.lower_bound(package_id.value);
|
||||
rit != by_pkg.end() && rit->package_id == package_id;
|
||||
++rit) {
|
||||
if (rit->version == version) {
|
||||
target = rit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
eosio::check(target != by_pkg.end(), "Релиз не найден");
|
||||
eosio::check(target->status != "withdrawn"_n, "Релиз уже отозван");
|
||||
|
||||
bool was_active_all = target->status == "active"_n
|
||||
&& target->scope.kind == "all"_n;
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
|
||||
by_pkg.modify(target, coopname, [&](auto &r) {
|
||||
r.status = "withdrawn"_n;
|
||||
// superseded_at оставляем как есть; meta обогащаем reason'ом.
|
||||
if (!reason.empty()) {
|
||||
if (!r.meta.empty()) r.meta += "\n";
|
||||
r.meta += "withdraw_reason: " + reason;
|
||||
}
|
||||
});
|
||||
|
||||
if (was_active_all) {
|
||||
packages.modify(pkg_it, coopname, [&](auto &p) {
|
||||
p.last_active_version = "";
|
||||
p.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
#define MEET "meet"
|
||||
#define LEDGER "ledger"
|
||||
#define LEDGER2 "ledger2"
|
||||
#define APPS "apps"
|
||||
|
||||
|
||||
/**
|
||||
@@ -163,6 +164,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
static constexpr eosio::name _meet = "meet"_n;
|
||||
static constexpr eosio::name _ledger = "ledger"_n;
|
||||
static constexpr eosio::name _ledger2 = "ledger2"_n;
|
||||
static constexpr eosio::name _apps = "apps"_n;
|
||||
static constexpr eosio::name _power_account = "eosio.power"_n;
|
||||
static constexpr eosio::name _saving_account = "eosio.saving"_n;
|
||||
|
||||
@@ -190,7 +192,8 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
"eosio.saving"_n,
|
||||
"ledger"_n,
|
||||
"ledger2"_n,
|
||||
"capital"_n
|
||||
"capital"_n,
|
||||
"apps"_n
|
||||
// Добавьте остальные стандартные или пользовательские контракты по необходимости
|
||||
};
|
||||
|
||||
|
||||
@@ -66,3 +66,9 @@
|
||||
#include "table_marketplace_requests.hpp"
|
||||
#include "table_marketplace_segments.hpp"
|
||||
#include "table_marketplace_shipments.hpp"
|
||||
|
||||
// apps (каталог приложений)
|
||||
#include "table_apps_packages.hpp"
|
||||
#include "table_apps_releases.hpp"
|
||||
#include "table_apps_subs.hpp"
|
||||
#include "table_apps_coops.hpp"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Запись каталога: кооператив, зарегистрированный в каталоге.
|
||||
*
|
||||
* Регистрация кооператива в каталоге — это часть процесса подключения
|
||||
* к каталогу приложений на Восходе (вне scope текущего MVP). Шаги:
|
||||
*
|
||||
* 1. Кооператив проходит onboarding в кабинете ВОСХОД.
|
||||
* 2. Кабинет генерирует/принимает выделенный subnet-signing-keypair.
|
||||
* Приватная часть остаётся у DC-оркестратора кооператива.
|
||||
* 3. Кабинет вызывает `regcoop(coopname, chain_id, subnet_label, signing_key)`
|
||||
* с публичной частью.
|
||||
*
|
||||
* Поле `signing_key` — это **отдельный** ключ, не равный `eosio::active`-ключу
|
||||
* аккаунта. Семантика:
|
||||
*
|
||||
* - Используется только off-chain в `CA-auth` для проверки подписи
|
||||
* `signed-request`-ов от DC-оркестратора (заголовок `X-Signature`).
|
||||
* - НЕ имеет on-chain прав (`active` остаётся отдельно).
|
||||
* - Ротация — отдельным action `setcoop(signing_key=new)`. Компрометация
|
||||
* `active` не лопает signed-request-цепочку и наоборот.
|
||||
* - Ранее выпущенные JWT (TTL 14 дней) остаются валидны после ротации;
|
||||
* только новые signed-request'ы должны идти от нового приват-ключа.
|
||||
*
|
||||
* `chain_id` (`checksum256`) — каноническая идентификация подсети, в которой
|
||||
* зарегистрирован кооператив (как в memory `subnet_identification` в
|
||||
* apps-catalog). `subnet_label` (`ru`/`by`/...) — human-label, удобный
|
||||
* для UI, scope-фильтрации в `releases`, и логирования.
|
||||
*
|
||||
* Контракт сам подпись не проверяет — он только хранит публичный ключ.
|
||||
* Проверка ECDSA secp256k1 — на стороне `CA-auth` через RPC
|
||||
* `get_table_rows apps coops`.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] coop {
|
||||
name coopname; // PK = Antelope name
|
||||
checksum256 chain_id; // подсеть (каноническая идентификация)
|
||||
name subnet_label; // human-label "ru"/"by" для UI/scope
|
||||
public_key signing_key; // subnet-signing-key (НЕ active)
|
||||
bool active;
|
||||
time_point_sec registered_at;
|
||||
time_point_sec key_rotated_at; // последняя ротация signing_key
|
||||
|
||||
uint64_t primary_key() const { return coopname.value; }
|
||||
uint64_t by_subnet() const { return subnet_label.value; }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"coops"_n, coop,
|
||||
eosio::indexed_by<"bysubnet"_n, eosio::const_mem_fun<coop, uint64_t, &coop::by_subnet>>>
|
||||
coops_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../consts.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Запись каталога: один зарегистрированный пакет.
|
||||
*
|
||||
* Идентификация: PK = `package_id` (Antelope `name`, ≤12 символов, [a-z1-5.]).
|
||||
* Имя выбирает разработчик при публикации; уникальность гарантируется блокчейном
|
||||
* (`emplace` фейлится, если такой `package_id` уже зарегистрирован).
|
||||
*
|
||||
* Имена пакета:
|
||||
* - `package_id` — кодовое неизменяемое имя в каталоге (этот PK).
|
||||
* - `package_name` — внешнее человекочитаемое имя (например, npm
|
||||
* `@voskhod/sports-club-tracker`, go-module path или OCI-image name).
|
||||
* Меняется через `setpkgname` (если потребуется) или re-publish; в текущей
|
||||
* схеме допускается изменение через rename-action в будущем.
|
||||
*
|
||||
* `compatible_subnets` — human-labels подсетей (`ru`, `by`, ...). Реальная
|
||||
* идентификация подсети — через `chain_id`, который хранится в `coops`/`subs`;
|
||||
* здесь labels достаточно, потому что они используются только для display
|
||||
* и грубой фильтрации в UI.
|
||||
*
|
||||
* `last_active_version` — semver самого свежего active-релиза c `scope.kind=all`.
|
||||
* Поле обновляется только при `setrelease` со scope=all и используется для
|
||||
* быстрого ответа `GET /v1/package/:id` без сканирования таблицы `releases`.
|
||||
* Canary-релизы это поле не трогают — оно отражает «версию по умолчанию»
|
||||
* для кооперативов вне canary-целей.
|
||||
*
|
||||
* \see lib/domain/table_apps_releases.hpp — связанные релизы (FK by_package).
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] package {
|
||||
name package_id; // PK, кодовое имя
|
||||
std::string package_name; // внешнее имя (npm/go/oci)
|
||||
name owner; // владелец (username разработчика)
|
||||
std::vector<name> compatible_subnets; // labels подсетей: "ru","by",...
|
||||
std::string last_active_version; // semver последнего scope=all active
|
||||
time_point_sec created_at;
|
||||
time_point_sec updated_at;
|
||||
|
||||
uint64_t primary_key() const { return package_id.value; }
|
||||
uint64_t by_owner() const { return owner.value; }
|
||||
uint64_t by_subnet() const {
|
||||
return compatible_subnets.empty() ? 0 : compatible_subnets[0].value;
|
||||
}
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"packages"_n, package,
|
||||
eosio::indexed_by<"byowner"_n, eosio::const_mem_fun<package, uint64_t, &package::by_owner>>,
|
||||
eosio::indexed_by<"bysubnet"_n, eosio::const_mem_fun<package, uint64_t, &package::by_subnet>>>
|
||||
packages_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "../core/utils.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Описание области видимости релиза.
|
||||
*
|
||||
* Возможные `kind`:
|
||||
* - `"all"_n` — релиз доступен всем кооперативам всех совместимых подсетей.
|
||||
* `targets` — пустой.
|
||||
* - `"subnet"_n` — релиз ограничен набором подсетей (по human-label
|
||||
* `subnet_label` из `coops`). `targets` — список labels.
|
||||
* - `"canary"_n` — релиз раскатывается на конкретные кооперативы.
|
||||
* `targets` — список `coopname`.
|
||||
*
|
||||
* Контракт scope-resolver не делает: он хранит scope как есть, а правило
|
||||
* приоритизации `canary > subnet > all` применяется на стороне `CA-auth`
|
||||
* при выпуске JWT.
|
||||
*/
|
||||
struct scope_t {
|
||||
name kind; // "all" | "subnet" | "canary"
|
||||
std::vector<name> targets; // labels подсетей или coopname'ы
|
||||
|
||||
EOSLIB_SERIALIZE(scope_t, (kind)(targets))
|
||||
};
|
||||
|
||||
/**
|
||||
* \brief Запись каталога: один релиз пакета.
|
||||
*
|
||||
* Жизненный цикл записи:
|
||||
*
|
||||
* 1. `setrelease` создаёт row со `status=active`, `superseded_at=0`.
|
||||
* 2. Любой существующий active с тем же `(package_id, scope)` переходит
|
||||
* в `superseded`, `superseded_at=now`.
|
||||
* 3. `withdraw` ставит `status=withdrawn` (релиз скомпрометирован /
|
||||
* отозван юридически). `withdrawn` НЕ удаляются по TTL — храним до
|
||||
* явной чистки.
|
||||
* 4. TTL retention: `superseded` старше 90 дней удаляются inline в
|
||||
* `setrelease` (до 50 за вызов) или по явному `cleanup(package_id)`.
|
||||
*
|
||||
* Решение НЕ хранить полную историю принято намеренно: каталог — это
|
||||
* source-of-truth текущего состояния, история живёт в git/docker registry.
|
||||
* RAM-budget при 1000 пакетов × ~10 релизов в окне = ~40K записей.
|
||||
*
|
||||
* Indexing strategy:
|
||||
* - PK `id` — auto-inc через `get_global_id(_apps, "release"_n)`.
|
||||
* - `bypackage` — выборка всех релизов пакета (`CA` для scope-resolver).
|
||||
* - `bypkgstat` — выборка active/superseded/withdrawn внутри пакета
|
||||
* (composite `package_id<<8 | status_code`); удобно для cleanup
|
||||
* «найти все superseded для пакета X».
|
||||
* - `bysuperseded` — sorted by `superseded_at`; используется в cleanup
|
||||
* для нахождения самых старых записей под удаление.
|
||||
*
|
||||
* \note `tarball_sha256` — содержательный хэш артефакта, нужен для
|
||||
* integrity-check при скачивании. Сама проверка — на `CA-auth`,
|
||||
* контракт хэш только хранит.
|
||||
*
|
||||
* \see lib/domain/table_apps_packages.hpp
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] release {
|
||||
uint64_t id;
|
||||
name package_id; // FK → packages.package_id
|
||||
std::string version; // semver
|
||||
scope_t scope;
|
||||
name status; // "active" | "superseded" | "withdrawn"
|
||||
time_point_sec published_at;
|
||||
time_point_sec superseded_at; // 0 пока active
|
||||
name moderated_by; // имя модератора (audit)
|
||||
checksum256 tarball_sha256;
|
||||
std::string meta; // JSON-строка: changelog, релиз-нотес и т.п.
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint64_t by_package() const { return package_id.value; }
|
||||
uint128_t by_pkg_status() const { return ((uint128_t)package_id.value << 64) | status.value; }
|
||||
uint64_t by_superseded() const { return superseded_at.sec_since_epoch(); }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"releases"_n, release,
|
||||
eosio::indexed_by<"bypackage"_n, eosio::const_mem_fun<release, uint64_t, &release::by_package>>,
|
||||
eosio::indexed_by<"bypkgstat"_n, eosio::const_mem_fun<release, uint128_t, &release::by_pkg_status>>,
|
||||
eosio::indexed_by<"bysuperseded"_n, eosio::const_mem_fun<release, uint64_t, &release::by_superseded>>>
|
||||
releases_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "../core/utils.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Запись каталога: подписка кооператива на пакет.
|
||||
*
|
||||
* Идентификация подписки — composite `(coopname, package_id, chain_id)`:
|
||||
* - `coopname` — кто подписан (Antelope name).
|
||||
* - `package_id` — на какой пакет.
|
||||
* - `chain_id` — в какой подсети действует подписка. Контракт `apps`
|
||||
* деплоится на корневой координационной KE-цепи и обслуживает
|
||||
* несколько подсетей; chain_id привязывает подписку к конкретной
|
||||
* подсети, в которой кооператив находится.
|
||||
*
|
||||
* `plan` — имя плана (`basic`, `premium`, ...). Это **только метка**:
|
||||
* биллинг (расчёт стоимости, приём оплаты, выставление счёта) — задача
|
||||
* кабинета ВОСХОД и контракта `pay.coopenomics`. Сюда приходит уже факт
|
||||
* «подписка активна с X по Y по плану Z».
|
||||
*
|
||||
* `regsub` идемпотентно по `(coopname, package_id)`: повторный вызов с
|
||||
* тем же ключом продлевает `end_at` (= `max(текущее, новое)`) и
|
||||
* обновляет `plan`/`active`. Это поведение сознательное — биллинг при
|
||||
* retry не должен бояться дубликатов.
|
||||
*
|
||||
* `expsub` НЕ удаляет row, а ставит `active=false`. Запись остаётся
|
||||
* для аудита истечения подписки. Cleanup просроченных записей — задача
|
||||
* отдельного периодического действия (вне MVP).
|
||||
*
|
||||
* Indexing:
|
||||
* - PK `id` — auto-inc.
|
||||
* - `bycooppkg` — composite (coopname, package_id) для idempotent upsert
|
||||
* и проверки «есть ли активная подписка коопа X на пакет Y».
|
||||
* - `bycoop` — все подписки одного кооператива.
|
||||
* - `byexpires` — sorted by `end_at` для будущего auto-cleanup.
|
||||
*
|
||||
* \see lib/domain/table_apps_coops.hpp — chain_id ↔ кооператив.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] sub {
|
||||
uint64_t id;
|
||||
name coopname;
|
||||
name package_id;
|
||||
checksum256 chain_id;
|
||||
name plan;
|
||||
bool active;
|
||||
time_point_sec start_at;
|
||||
time_point_sec end_at;
|
||||
time_point_sec created_at;
|
||||
time_point_sec updated_at;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint128_t by_cooppkg() const { return ((uint128_t)coopname.value << 64) | package_id.value; }
|
||||
uint64_t by_coop() const { return coopname.value; }
|
||||
uint64_t by_expires() const { return end_at.sec_since_epoch(); }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"subs"_n, sub,
|
||||
eosio::indexed_by<"bycooppkg"_n, eosio::const_mem_fun<sub, uint128_t, &sub::by_cooppkg>>,
|
||||
eosio::indexed_by<"bycoop"_n, eosio::const_mem_fun<sub, uint64_t, &sub::by_coop>>,
|
||||
eosio::indexed_by<"byexpires"_n, eosio::const_mem_fun<sub, uint64_t, &sub::by_expires>>>
|
||||
subs_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,44 @@
|
||||
# Образ-артефакт для всей пачки контрактов кооперативной экономики.
|
||||
#
|
||||
# Назначение: переносить wasm/abi всех контрактов между этапами CI/CD
|
||||
# и потребителями (ke-bootstrap, локальные dev-окружения, аудит).
|
||||
# Сам по себе образ ничего не запускает на блокчейне — его пара —
|
||||
# отдельный образ ke-bootstrap, который при старте ноды деплоит
|
||||
# контракты на неё через `cleos set contract`.
|
||||
#
|
||||
# Содержимое:
|
||||
# /contracts/<name>/<name>.wasm
|
||||
# /contracts/<name>/<name>.abi
|
||||
# /contracts/manifest.json — список контрактов + sha256 + build_mode
|
||||
#
|
||||
# Способы потребления:
|
||||
#
|
||||
# 1) Multi-stage в bootstrap-образе (production):
|
||||
# FROM dicoop/contracts:testnet AS contracts
|
||||
# ...
|
||||
# COPY --from=contracts /contracts /work/contracts
|
||||
#
|
||||
# 2) Volume-mount (debug / ad-hoc):
|
||||
# docker run --rm -v $PWD/out:/out dicoop/contracts:testnet
|
||||
# → ./out/<name>/{wasm,abi} + manifest.json
|
||||
#
|
||||
# 3) Точечно один контракт:
|
||||
# docker run --rm -v $PWD/out:/out dicoop/contracts:testnet copy apps
|
||||
# → ./out/apps/{wasm,abi}
|
||||
#
|
||||
# Сборка артефактов идёт в CI до docker build через CDT-образ
|
||||
# `dicoop/blockchain_v5.1.1:dev` (см. components/contracts/build-all.sh).
|
||||
# Этот Dockerfile только упаковывает результат в slim alpine.
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN mkdir -p /contracts
|
||||
COPY contracts/ /contracts/
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
LABEL org.opencontainers.image.title="coopenomics-contracts"
|
||||
LABEL org.opencontainers.image.description="WASM+ABI артефакты всех контрактов кооперативной экономики"
|
||||
LABEL org.opencontainers.image.source="https://github.com/coopenomics/mono"
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["copy"]
|
||||
@@ -0,0 +1,111 @@
|
||||
# `dicoop/contracts` — образ-артефакт всех контрактов
|
||||
|
||||
Контейнер собирает в один slim alpine-образ wasm/abi всех контрактов
|
||||
кооперативной экономики (`apps, marketplace, ledger, meet, loan, wallet,
|
||||
capital, branch, contributor, fund, draft, soviet, registrator, gateway,
|
||||
system, test`).
|
||||
|
||||
## Зачем отдельный образ
|
||||
|
||||
- Нода (`dicoop/blockchain_v5.1.1`) — это runtime с `nodeos`. Она
|
||||
одна и не должна повторно собираться вместе с контрактами.
|
||||
- Сборка контрактов (через CDT) — отдельный шаг CI, выполняется в том
|
||||
же `dicoop/blockchain_v5.1.1`-образе но в режиме toolchain.
|
||||
- Деплой — третий артефакт (`ke-bootstrap`), который читает контракты
|
||||
отсюда и применяет их к ноде через `cleos set contract`.
|
||||
|
||||
Эта декомпозиция даёт переиспользование: одна нода + N версий контрактов
|
||||
+ один bootstrap.
|
||||
|
||||
## Layout внутри образа
|
||||
|
||||
```
|
||||
/contracts/
|
||||
├── apps/
|
||||
│ ├── apps.wasm
|
||||
│ └── apps.abi
|
||||
├── marketplace/
|
||||
│ ├── marketplace.wasm
|
||||
│ └── marketplace.abi
|
||||
├── ...
|
||||
└── manifest.json # имена + sha256 + build_mode + git sha + timestamp
|
||||
```
|
||||
|
||||
## Тэги
|
||||
|
||||
| Ветка | Tag | `IS_TESTNET` |
|
||||
| --- | --- | --- |
|
||||
| `dev` | `dicoop/contracts:dev` | ON |
|
||||
| `testnet` | `dicoop/contracts:testnet` | ON |
|
||||
| `main` | `dicoop/contracts:main` + `:latest` | OFF |
|
||||
|
||||
Дополнительно для воспроизводимости каждый push публикуется как
|
||||
`dicoop/contracts:<tag>-<short_sha>`.
|
||||
|
||||
## Использование
|
||||
|
||||
### 1. Multi-stage в bootstrap-образе (production)
|
||||
|
||||
```dockerfile
|
||||
FROM dicoop/contracts:testnet AS contracts
|
||||
|
||||
FROM dicoop/blockchain_v5.1.1:dev
|
||||
COPY --from=contracts /contracts /work/contracts
|
||||
# ... bootstrap-script использует /work/contracts при cleos set contract
|
||||
```
|
||||
|
||||
### 2. Volume-mount (debug, локальные dev-окружения)
|
||||
|
||||
```bash
|
||||
mkdir -p ./out
|
||||
docker run --rm -v "$PWD/out:/out" dicoop/contracts:testnet
|
||||
ls out/
|
||||
# apps/ marketplace/ ledger/ ... manifest.json
|
||||
```
|
||||
|
||||
### 3. Точечно один контракт
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD/out:/out" dicoop/contracts:testnet copy apps
|
||||
ls out/apps/
|
||||
# apps.wasm apps.abi
|
||||
```
|
||||
|
||||
### 4. Аудит / диагностика
|
||||
|
||||
```bash
|
||||
# Список всех контрактов в образе:
|
||||
docker run --rm dicoop/contracts:testnet list
|
||||
|
||||
# Контрольные суммы:
|
||||
docker run --rm dicoop/contracts:testnet sha256
|
||||
|
||||
# Манифест с метаданными сборки:
|
||||
docker run --rm dicoop/contracts:testnet manifest | jq .
|
||||
```
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
```bash
|
||||
# 1. Скомпилировать контракты:
|
||||
cd components/contracts
|
||||
./build-all.sh prod # или ./build-all.sh test для testnet-режима
|
||||
|
||||
# 2. Подготовить docker context:
|
||||
mkdir -p docker/.context/contracts
|
||||
for c in apps marketplace ledger meet loan wallet capital branch \
|
||||
contributor fund draft soviet registrator gateway system test; do
|
||||
if [ -f build/contracts/$c/$c.wasm ]; then
|
||||
mkdir -p docker/.context/contracts/$c
|
||||
cp build/contracts/$c/$c.wasm docker/.context/contracts/$c/
|
||||
cp build/contracts/$c/$c.abi docker/.context/contracts/$c/
|
||||
fi
|
||||
done
|
||||
cp docker/Dockerfile docker/entrypoint.sh docker/.context/
|
||||
|
||||
# 3. Build:
|
||||
docker build -t dicoop/contracts:local ./docker/.context
|
||||
```
|
||||
|
||||
В CI этот pipeline автоматизирован — см.
|
||||
`.github/workflows/build-contracts.yaml`.
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint образа dicoop/contracts.
|
||||
# Команды:
|
||||
# copy [<name>] — копировать в $OUT_DIR (по умолчанию /out):
|
||||
# без имени — всё дерево /contracts;
|
||||
# с именем — только /contracts/<name>/.
|
||||
# manifest — вывести manifest.json в stdout.
|
||||
# sha256 [<name>] — sha256 wasm/abi для всех или одного контракта.
|
||||
# ls [<name>] — листинг /contracts (рекурсивно для всего, или один).
|
||||
# list — печать имён контрактов через newline (для скриптов).
|
||||
#
|
||||
# Default: copy (всё дерево).
|
||||
set -eu
|
||||
|
||||
cmd="${1:-copy}"
|
||||
shift || true
|
||||
|
||||
list_names() {
|
||||
# Имена контрактов = имена subdirectories в /contracts.
|
||||
for d in /contracts/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
basename "$d"
|
||||
done
|
||||
}
|
||||
|
||||
assert_contract() {
|
||||
name="$1"
|
||||
if [ ! -d "/contracts/$name" ]; then
|
||||
echo "Контракт '$name' не найден в образе. Доступные:" >&2
|
||||
list_names >&2
|
||||
exit 65
|
||||
fi
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
copy)
|
||||
DEST="${OUT_DIR:-/out}"
|
||||
mkdir -p "$DEST"
|
||||
if [ $# -ge 1 ]; then
|
||||
name="$1"
|
||||
assert_contract "$name"
|
||||
mkdir -p "$DEST/$name"
|
||||
cp /contracts/"$name"/*.wasm "$DEST/$name/" 2>/dev/null || true
|
||||
cp /contracts/"$name"/*.abi "$DEST/$name/" 2>/dev/null || true
|
||||
echo "[contracts] copied $name → $DEST/$name"
|
||||
else
|
||||
cp -R /contracts/. "$DEST/"
|
||||
echo "[contracts] copied all artifacts → $DEST"
|
||||
fi
|
||||
ls -la "$DEST" 2>/dev/null || true
|
||||
;;
|
||||
manifest)
|
||||
cat /contracts/manifest.json
|
||||
;;
|
||||
sha256)
|
||||
if [ $# -ge 1 ]; then
|
||||
name="$1"
|
||||
assert_contract "$name"
|
||||
sha256sum /contracts/"$name"/*.wasm /contracts/"$name"/*.abi
|
||||
else
|
||||
find /contracts -name '*.wasm' -o -name '*.abi' | sort | xargs sha256sum
|
||||
fi
|
||||
;;
|
||||
ls)
|
||||
if [ $# -ge 1 ]; then
|
||||
name="$1"
|
||||
assert_contract "$name"
|
||||
ls -la /contracts/"$name"/
|
||||
else
|
||||
ls -la /contracts/
|
||||
echo "---"
|
||||
find /contracts -mindepth 2 -maxdepth 2 | sort
|
||||
fi
|
||||
;;
|
||||
list)
|
||||
list_names
|
||||
;;
|
||||
*)
|
||||
cat >&2 <<EOF
|
||||
Usage: docker run --rm dicoop/contracts <cmd> [name]
|
||||
|
||||
Commands:
|
||||
copy [<name>] Copy all contracts (or one) to \$OUT_DIR (default: /out).
|
||||
manifest Print manifest.json (list of contracts, sha256, build mode).
|
||||
sha256 [<name>] sha256 of wasm/abi (all or one contract).
|
||||
ls [<name>] List directory contents.
|
||||
list Print contract names, one per line.
|
||||
EOF
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
---
|
||||
project_name: 'Платформа Цифрового Кооператива — Синхронизация узла с блокчейном'
|
||||
user_name: 'ant'
|
||||
date: '2026-04-24'
|
||||
sections_completed:
|
||||
- technology_stack
|
||||
- language_rules
|
||||
- framework_rules
|
||||
- composite_entity
|
||||
- dispatch_pipeline
|
||||
- write_mutation
|
||||
- read_path
|
||||
- fork_handling
|
||||
- pending_pool
|
||||
- testing_rules
|
||||
- code_quality
|
||||
- workflow
|
||||
- dont_miss
|
||||
- usage_guidelines
|
||||
status: complete
|
||||
rule_count: 80+
|
||||
optimized_for_llm: true
|
||||
existing_patterns_found: 16
|
||||
source_documents:
|
||||
- 'ARCH: Синхронизация узла с блокчейном v1 (c3-arch-sinkhronizatsiya-uzla-s-blokcheynom-v1.md)'
|
||||
- 'ARCH: Интеграция контроллера с parser2 v1 (4f-arch-integratsiya-kontrollera-s-parser2-v1.md)'
|
||||
- 'Brief платформы (74-produktovyy-brif-platformy-tsifrovogo-kooperativa.md)'
|
||||
target_repo: /home/admin/mono-ai-5/components/controller
|
||||
---
|
||||
|
||||
# Project Context for AI Agents
|
||||
|
||||
_Критичные правила и паттерны для AI-агентов при работе с sync-кодом в `controller/`. Фокус — unobvious, часто забываемые детали. НЕ дублирует арх-документы, а выжимает enforcement._
|
||||
|
||||
Перед написанием кода — обязательно прочитать **`ARCH: Синхронизация узла с блокчейном v1`** и **`ARCH: Интеграция контроллера с parser2 v1`** (в `components/14-versiya-3/requirements/`).
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack & Versions
|
||||
|
||||
- **TypeScript 5.x** (strict mode; `noImplicitAny`, `strictNullChecks`)
|
||||
- **Node 20 LTS**
|
||||
- **NestJS** (`@nestjs/common`, `@nestjs/core`, `@nestjs/event-emitter`)
|
||||
- **TypeORM** + **PostgreSQL 14+**
|
||||
- **Redis 7+** (AOF + RDB **mandatory** для prod — startup validation)
|
||||
- **`@coopenomics/parser2` v1.0.0 MVP** (пост-миграция) + **`@coopenomics/coopos-ship-reader`** транзитивно
|
||||
- **`@wharfkit/antelope`** — chain SDK для submit
|
||||
- **EventEmitter2** — внутренняя шина (`delta::*`, `action::*`, `fork::*`)
|
||||
- **Jest** — unit + e2e
|
||||
- **pnpm 9+** + **Lerna** monorepo
|
||||
- **reflect-metadata** — DI + декораторы (`@DomainKey`, `@SyncBehaviour`, `@Versioned`)
|
||||
- **Feature flag** `USE_PARSER2_CLIENT` — поэтапный переход с legacy `BlockchainConsumerService` на `ParserClient`
|
||||
|
||||
---
|
||||
|
||||
## Critical Implementation Rules
|
||||
|
||||
### Language-Specific (TypeScript)
|
||||
|
||||
- **Bigint из PostgreSQL приходит как STRING.** Всегда `Number(blockNum) < Number(currentBlockNum)`, никогда не полагаться на `<`/`>` для `bigint`-колонок напрямую.
|
||||
- **`Object.assign(this, blockchainData)` запрещено** в sync-сущностях — ломает типизацию. Только явное копирование полей.
|
||||
- **Lowercase hash полей (`project_hash`, `listing_hash`)** — нормализация **в конструкторе/mapValue**, НЕ в mapper'ах и НЕ повторно в `updateFromBlockchain`.
|
||||
- **Discriminated union для write-mutation response:** `{ status: 'applied' | 'pending' | 'failed' | 'conflict' }`. Клиент должен switch на статусе.
|
||||
- **`Number(value.id)` + `String(value.coopname)`** — explicit typing при `mapValue`; не spread.
|
||||
- **Imports из `~/shared/...`** (tsconfig paths) для kernel, не relative `../../../shared/`.
|
||||
|
||||
### Framework-Specific
|
||||
|
||||
**NestJS / DI:**
|
||||
- Декораторы `@DomainKey({ primary, sync })` + `@SyncBehaviour({ forkPolicy, dlq })` + `@Versioned({ strategy })` — на sync-service классе. Metadata читается через `Reflect.getMetadata('sync:config', target)`.
|
||||
- `@Inject(ENTITY_REPOSITORY)` token — symbol, определён в `domain/repositories/{entity}.repository.ts`.
|
||||
- Dynamic modules через `{Contract}SyncModule.forEntity(Entity, TypeormEntity, Mapper)` — одна строка регистрации в `{contract}.module.ts`.
|
||||
|
||||
**TypeORM:**
|
||||
- Миграции `migrations/{timestamp-ms}-{name}.ts`. `synchronize: true` **запрещено** в prod.
|
||||
- `@Column({ type: 'bigint', nullable: true })` для `block_num`. Для `jsonb` — `@Column({ type: 'jsonb' })`.
|
||||
- `ADD COLUMN NOT NULL` на больших таблицах — **двухэтапно**: ADD nullable → backfill → ALTER NOT NULL.
|
||||
- Repository `extends BaseBlockchainRepository<DomainEntity, TypeormEntity>`. `findBySyncKey`, `createIfNotExists`, `deleteByBlockNumGreaterThan`, `restoreFromVersions` — **наследуются**, не реализовывать руками.
|
||||
|
||||
**parser2 integration:**
|
||||
- `ParserClient` subscribe с `subscriptionId = "controller-${coopname}"`, `consumerName = "primary"` (детерминирован), `startFromBlock: 'last_known'`.
|
||||
- Redis keys per-chain: `ce:parser2:<chain_id>:events`. НЕ писать в этот stream руками.
|
||||
- `event_id` формат: `${chain}:{kind}:{block_num}:{block_id_short}:{natural_key}` — вычисляется parser2, consumer читает как есть.
|
||||
|
||||
**EventEmitter2:**
|
||||
- `delta::{contract}::{table}` — внутренняя шина для syncer'ов.
|
||||
- `{contract}{Entity}Updated` / `Deleted` / `RolledBack` / `PendingRetry` / `Failed` — **pubsub канал** для GraphQL subscriptions. Per-contract, не global.
|
||||
- `entitysynced::{contract}::{table}` — для business-side-effect listeners (Matrix / Notification / и т.п.).
|
||||
|
||||
### Composite-Entity (ADR-008) — СТРОГО
|
||||
|
||||
- Namespaced: `entity.db.X` (DB-поля) / `entity.bc?.Y` (blockchain, nullable) / `entity.derived.Z` (computed getters).
|
||||
- **НЕ** писать `entity.X` напрямую — ломает изоляцию.
|
||||
- Конструктор `(databaseData, blockchainData?)` — обязан `throw` на sync-key mismatch.
|
||||
- `updateFromBlockchain` возвращает **новый экземпляр** (immutable) или мутирует только `this.bc`, `this.block_num`, `this.present` — БЕЗ `Object.assign`.
|
||||
- `derived` getter — детерминирован (NO `new Date()` в конструкторе / getter — ломает snapshot tests).
|
||||
- Все signed-document поля нормализуются через `AbstractDeltaMapper.normalizeSignedDocuments` на основе `signedDocumentFields: SignedDocField[]` декларативно, НЕ руками в mapper.
|
||||
|
||||
### Dispatch pipeline (ADR-002, ADR-009) — СТРОГО
|
||||
|
||||
В `BlockchainParserAdapterService.dispatch`:
|
||||
|
||||
```
|
||||
1. dedup check (event_id) → return если exists
|
||||
2. save sync (через syncer)
|
||||
3. dedup.mark
|
||||
4. wake waiters (sync_key + block_num match)
|
||||
5. emit internal bus (delta:: immediate / action:: delayed 3s)
|
||||
6. emit pubsub {Entity}Updated (с entity из PG, не из raw delta)
|
||||
```
|
||||
|
||||
- **Никогда** emit pubsub до save (INV-12).
|
||||
- **Никогда** emit action immediate — только `setTimeout(emit, config.blockchain.actionEmitDelayMs)`.
|
||||
- **Никогда** swallow ошибку в dispatch — re-throw либо DLQ.
|
||||
|
||||
### Write-mutation pattern (ADR-009, ADR-012) — СТРОГО
|
||||
|
||||
Application service:
|
||||
```typescript
|
||||
// 1. Pre-computed sync_key (deterministic)
|
||||
const expectedHash = computeEntityHash(input);
|
||||
|
||||
// 2. Pre-gate: нет ли уже in-flight?
|
||||
const inflight = await this.pool.findActive(contract, table, expectedHash);
|
||||
if (inflight) return { status: 'conflict', existing_tx_hash: inflight.tx_hash, attempt: inflight.retry_count };
|
||||
|
||||
// 3. Submit через pool (placeholder → submit → finalize)
|
||||
const tx = await this.pool.submitWithPool({ user_id, contract, table, sync_key: expectedHash, action_name }, input);
|
||||
|
||||
// 4. Wait-for-delta
|
||||
const entity = await this.adapter.waitForDelta(contract, table, expectedHash, tx.applied_block, config.writeWaitDeltaMs);
|
||||
|
||||
// 5. Discriminated return
|
||||
if (entity) return { entity, tx_hash: tx.tx_hash, status: 'applied' };
|
||||
return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
```
|
||||
|
||||
**Никогда**: `chainPort.submitTx(...)` напрямую в resolver/service — только `pool.submitWithPool`.
|
||||
**Никогда**: `chainPort.getX(...)` в read-path — только `repository.findBySyncKey`.
|
||||
|
||||
### Read-path (ADR-011) — СТРОГО
|
||||
|
||||
- Queries / resolvers / application reads → **только Repository из Postgres**.
|
||||
- `capitalBlockchainPort.getProject` / `getSegment` и аналоги — **retired из read-path**. Остаются только в:
|
||||
- Reconciliation cron (DEC-015).
|
||||
- OrphanPendingReconciler (17.6) — chain lookup для reconcile submitting-placeholder.
|
||||
- Admin / forensic CLI (не runtime).
|
||||
|
||||
Если repository вернул `null` — передать это наверх как pending state, НЕ fallback на RPC.
|
||||
|
||||
### Fork handling (ADR-005) — СТРОГО
|
||||
|
||||
- `@OnEvent('fork::*')` broadcast — **deprecated**. Использовать `ForkRegistry.register(handler)` в syncer onInit + вызов `forkRegistry.runAll(blockNum)` sequentially.
|
||||
- Fork = ordered event в том же stream. Sequential XREADGROUP (single-active) = natural barrier.
|
||||
- `ForkRegistry.runAll` — **последовательный for-await-of**, НЕ `Promise.all` (FK зависимости).
|
||||
- После rollback entities → `dedupRepo.deleteAfterBlock` → `pool.handleFork` (retry) → emit `{Entity}RolledBack`.
|
||||
|
||||
### Pending Pool (ADR-012, раздел 17) — СТРОГО
|
||||
|
||||
- State machine: `submitting → pending → retrying → confirmed | abandoned`. Переходы — CAS `UPDATE ... WHERE status = 'expected'`.
|
||||
- `placeholder pattern` для crash-window: INSERT `submitting` → submit → UPDATE `pending`. На boot — `OrphanPendingReconciler` сканит chain history по user_id для матча.
|
||||
- Correlation: **sync_key primary** (deterministic) + **tx_hash fallback** (non-deterministic).
|
||||
- **Кооператив подписывает tx, не пайщик.** Retry берёт текущий активный ключ кооператива из `chainPort`, user session не требуется.
|
||||
- Retention: terminal states `confirmed`/`abandoned` живут `pendingTxRetentionHours` (default 24h).
|
||||
|
||||
### Testing Rules
|
||||
|
||||
**Обязательные сценарии для composite-entity:**
|
||||
- Конструктор с `db`-only → entity валидна.
|
||||
- Конструктор с `db + bc` → entity полная.
|
||||
- `db.sync_key !== bc.sync_key` → `throw`.
|
||||
- `update(bc, blockNum)` → новый экземпляр (immutable), старый не изменился.
|
||||
- `derived` детерминирован (два вызова → одинаково).
|
||||
|
||||
**Обязательные сценарии для Adapter:**
|
||||
- `dispatch` — порядок: dedup → save → mark → wake → emit bus → emit pubsub.
|
||||
- `dispatch` duplicate event_id → no-op.
|
||||
- `applyFork` — sequential через ForkRegistry.
|
||||
- `waitForDelta` — resolves на match, null на timeout.
|
||||
|
||||
**Обязательные для Pool:**
|
||||
- `submitWithPool` crash-window: crash между INSERT placeholder и submit → OrphanReconciler находит в chain history.
|
||||
- `handleFork` при active retry — serialized.
|
||||
- `retry` uses current cooperative key (после key rotation).
|
||||
- `confirmByKey` guard: stale delta (block_num < applied) не confirm'ит.
|
||||
|
||||
**E2E (testcontainers + parser2 staging):**
|
||||
- `fork-during-delta` — симулирует parser отправляющий delta+fork+delta в 100ms окне.
|
||||
- `crash-between-submit-and-register` — прерывает процесс между chain submit и pool finalize.
|
||||
- `pool-retry-on-fork` — pool auto-retry после fork.
|
||||
|
||||
**Mock ban:**
|
||||
- **Не** mock'ать TypeORM в интеграционных тестах (testcontainers Postgres).
|
||||
- **Не** mock'ать parser2 в e2e — использовать staging SHiP или dockerized parser2.
|
||||
|
||||
### Code Quality & Style
|
||||
|
||||
**Naming (жёстко):**
|
||||
- Entity classes: `{Name}DomainEntity`, `{Name}TypeormEntity`.
|
||||
- Interfaces: `I{Name}DomainInterfaceBlockchainData`, `I{Name}DomainInterfaceDatabaseData`.
|
||||
- Mappers: `{Name}DeltaMapper`.
|
||||
- Syncers: `{Name}SyncService`.
|
||||
- Repositories: `{Name}Repository` (interface) + `{Name}TypeormRepository` (impl) + `{NAME}_REPOSITORY` (DI token).
|
||||
- Files: kebab-case с суффиксом (`project.entity.ts`, `project.typeorm-entity.ts`, `project-delta.mapper.ts`, `project-sync.service.ts`).
|
||||
|
||||
**Paths (жёстко):**
|
||||
- Per-contract: `extensions/{contract}/{domain|infrastructure|application}/...`.
|
||||
- Kernel: `shared/sync/...`, `shared/decorators/...`, `shared/pubsub/...`, `shared/mappers/...`.
|
||||
- Transport: `infrastructure/blockchain/`, `infrastructure/parser2/`, `infrastructure/redis/`.
|
||||
- Cross-cutting domain: `domain/pending-tx/`, `domain/breach/`, `domain/reconciliation/`.
|
||||
|
||||
**Config (ENV-sourced, не hardcode):**
|
||||
- `BLOCKCHAIN_ACTION_EMIT_DELAY_MS` default 3000
|
||||
- `BLOCKCHAIN_WRITE_WAIT_DELTA_MS` default 3000
|
||||
- `BLOCKCHAIN_ROLLBACK_HORIZON_BLOCKS` default 1000
|
||||
- `BLOCKCHAIN_RECONCILE_CRON` default `'0 * * * *'`
|
||||
- `BLOCKCHAIN_RECONCILE_SAMPLE_SIZE` default 100
|
||||
- `BLOCKCHAIN_RECONCILE_TOLERANCE_BLOCKS` default 10
|
||||
- `BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS` default 30000
|
||||
- `BLOCKCHAIN_DLQ_MAX_RETRIES` default 5
|
||||
- `BLOCKCHAIN_MAX_TX_RETRIES` default 3
|
||||
- `BLOCKCHAIN_PENDING_TX_MAX_AGE_SECONDS` default 3600
|
||||
- `BLOCKCHAIN_PENDING_TX_RETENTION_HOURS` default 24
|
||||
|
||||
Добавлять новый magic number? Сначала добавь в `config/blockchain.config.ts` + env var.
|
||||
|
||||
**Comments:**
|
||||
- Нет комментариев к WHAT (код сам скажет). Только WHY + invariants + links на ADR в non-obvious местах.
|
||||
- JSDoc для интерфейсов в `domain/interfaces/` — да. Для сервисов — нет (имена self-documenting).
|
||||
|
||||
### Development Workflow
|
||||
|
||||
- Branch naming: `feature/549-N-short-slug`, `fix/X-slug`, `arch/Y-slug`.
|
||||
- Commit message: `[549-N][@ant] type: короткое описание` (существующая конвенция).
|
||||
- PR — в обязательном порядке проходит **enforcement checklist** из секции 17 arch-документа.
|
||||
- Migration review: автор migrate → ревью от второго инженера + проверка двухэтапности.
|
||||
- Feature flag `USE_PARSER2_CLIENT` перед merge на main — рассматривается PR-mergability.
|
||||
|
||||
---
|
||||
|
||||
## Critical Don't-Miss Rules (Anti-Patterns)
|
||||
|
||||
### ❌ Silent data loss patterns
|
||||
|
||||
- `try { await syncer.process() } catch (e) { logger.error(e); return null; }` — **запрещено**. Re-throw или DLQ.
|
||||
- `setTimeout(async () => { save + emit }, 3000)` — ACK раньше save, **silent loss при crash**. Pattern: save sync → ACK → `setTimeout(emit, 3000)`.
|
||||
- `mapper.mapDeltaToBlockchainData(d) === null → warn → cursor вперёд` без алерта — pattern breaking. Всегда метрика + alert на unknown-version.
|
||||
|
||||
### ❌ Read-path anti-patterns
|
||||
|
||||
- `await capitalBlockchainPort.getProject(hash)` в resolver / application — **retire**. Только `repository.findBySyncKey`.
|
||||
- RPC fallback "если PG не отдал" — **запрещено**. Если PG null → pending status наверх.
|
||||
|
||||
### ❌ Composite-entity anti-patterns
|
||||
|
||||
- `project.matrix_room_id` (flat access) — **запрещено**. Правильно: `project.db.matrix_room_id`.
|
||||
- `project.master` (flat access) — **запрещено**. Правильно: `project.bc?.master`.
|
||||
- `Object.assign(this, blockchainData)` в update — **запрещено**.
|
||||
- Новый `new Date()` в конструкторе или derived-getter — **запрещено** (ломает snapshot-tests).
|
||||
|
||||
### ❌ Fork handling anti-patterns
|
||||
|
||||
- `Promise.all(registry.map(...))` для rollback — **запрещено** (FK зависимости). For-await-of sequential.
|
||||
- `@OnEvent('fork::*')` в syncer без ForkRegistry — **deprecated**. Миграция обязательна.
|
||||
|
||||
### ❌ Write-mutation anti-patterns
|
||||
|
||||
- `chainPort.submitTx()` напрямую — **запрещено** (теряется pool tracking). Только через `pool.submitWithPool`.
|
||||
- `hardcoded 3000` в setTimeout — **запрещено**. Только `config.blockchain.actionEmitDelayMs` / `writeWaitDeltaMs`.
|
||||
- Pre-gate отсутствует (два click'а пайщика создают duplicate submit) — **запрещено**. Всегда `pool.findActive` перед submit.
|
||||
|
||||
### ❌ Idempotency anti-patterns
|
||||
|
||||
- Skip event_id dedup "потому что там block_num guard есть" — **запрещено**. Dedup FIRST в pipeline.
|
||||
- Использовать raw `block_num` вместо `event_id` для dedup — пропускает branch fork scenarios.
|
||||
|
||||
### ❌ Subscription anti-patterns
|
||||
|
||||
- Emit pubsub **до** save в PG — **запрещено** (INV-12). Клиент получит "updated" event на несохранённое состояние.
|
||||
- Global channel `entityUpdated(contract, ...)` — **запрещено**. Per-contract isolation обязателен.
|
||||
- Emit raw delta в subscription payload — **запрещено**. Payload = domain entity, read из PG.
|
||||
|
||||
### ⚠️ Edge cases — обязательно handle
|
||||
|
||||
- `block_num = null` — сущность создана до sync. НЕ rollback'ится форком. После breach `force_restore: ignore_local_edits` flag.
|
||||
- `present = false` → `blockchainData = undefined` (INV-02). Domain читает как валидное (удалённое в блокчейне).
|
||||
- `parent_hash === '0000...0000'` — sentinel для "нет parent". Константа `ZERO_HASH` в shared, НЕ magic string.
|
||||
- `mapStatusToDomain` unknown status → `UNDEFINED` fallback **плюс** alert (schema drift).
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Кооперативный ключ — в `config.cooperative.signingKey`, НЕ в коде. Startup validation.
|
||||
- User ID в `pending_tx` — только для notifications, НЕ для signing.
|
||||
- Subscription authorization — per-cooperative scope обязателен (OQ-T15).
|
||||
- Breach freeze — админ endpoint **только** через multi-sig (TODO: Phase 2).
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- `JSON.stringify` на сущностях с nested signed-documents — использовать `json-stable-stringify` для canonical checksum.
|
||||
- Reconciliation cron — sample N=100 rows, **не** full scan на hot path.
|
||||
- IPFS fetch для signed-doc — **lazy resolver вне consumer critical path**, не в mapper.
|
||||
- `waitForDelta` memory leak — timer cleanup обязателен (см. INV-T10).
|
||||
|
||||
---
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
**Для AI-агентов:**
|
||||
- Прочитать этот файл **перед** написанием любого sync-кода в `controller/`.
|
||||
- Следовать ВСЕМ правилам дословно.
|
||||
- При сомнении — выбирать более строгий вариант (не fallback на RPC, не swallow ошибку, не flat access).
|
||||
- Обновлять этот файл если возникает новый паттерн который AI-агенты могут пропустить.
|
||||
|
||||
**Для людей:**
|
||||
- Держать файл LEAN и ориентированным на агентов.
|
||||
- Обновлять при изменении стека / ADR.
|
||||
- Периодически (раз в квартал) вычищать очевидные правила.
|
||||
- **Не** добавлять описание "что делает код" — только "что легко сломать".
|
||||
|
||||
**Источники истины (при противоречии):**
|
||||
1. ADR в `ARCH: Синхронизация узла с блокчейном v1` — главный источник архитектурных решений.
|
||||
2. `ARCH: Интеграция контроллера с parser2 v1` — транспортный слой.
|
||||
3. Этот файл — enforcement выжимка, не overrides ADR.
|
||||
|
||||
Last Updated: 2026-04-24
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -170,6 +170,7 @@
|
||||
"threads": "^1.7.0",
|
||||
"ts-jest": "^29.1.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsd": "^0.31.0",
|
||||
"tsoa": "^6.4.0",
|
||||
"typedoc": "^0.25.13",
|
||||
@@ -213,7 +214,6 @@
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-sort-imports": "^1.8.9",
|
||||
"supertest": "^7.2.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
|
||||
+2
-2
@@ -53,8 +53,8 @@ export class StoryFilterInputDTO {
|
||||
|
||||
@Field(() => Boolean, {
|
||||
nullable: true,
|
||||
description: 'Показывать требования задач при фильтрации по project_hash',
|
||||
defaultValue: true,
|
||||
description: 'Показывать требования задач при фильтрации по project_hash (по умолчанию false: задачные требования живут на странице задачи и не аккумулируются на проект/компонент)',
|
||||
defaultValue: false,
|
||||
})
|
||||
show_issues_requirements?: boolean;
|
||||
}
|
||||
|
||||
+18
-7
@@ -401,11 +401,13 @@ export class GenerationService {
|
||||
return [];
|
||||
}
|
||||
|
||||
let anchorProjectHash = story.project_hash?.trim() ?? '';
|
||||
if (!anchorProjectHash && story.issue_hash) {
|
||||
const issue = await this.issueRepository.findByIssueHash(story.issue_hash);
|
||||
anchorProjectHash = issue?.project_hash?.trim() ?? '';
|
||||
// Требования к задаче (issue_hash задан) видны только на странице задачи и не публикуются
|
||||
// в проектные/компонентные matrix-комнаты, чтобы не зашумлять основной канал проекта.
|
||||
if (story.issue_hash && story.issue_hash.trim() !== '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const anchorProjectHash = story.project_hash?.trim() ?? '';
|
||||
if (!anchorProjectHash) {
|
||||
return [];
|
||||
}
|
||||
@@ -665,8 +667,10 @@ export class GenerationService {
|
||||
// Определяем, нужно ли включать требования дочерних компонентов
|
||||
const showComponentsRequirements = filter.show_components_requirements !== false; // По умолчанию true
|
||||
|
||||
// Определяем, нужно ли включать требования задач
|
||||
const showIssuesRequirements = filter.show_issues_requirements !== false; // По умолчанию true
|
||||
// Задачные требования (story с issue_hash) живут только на странице задачи и не
|
||||
// аккумулируются на проект/компонент: дефолт false, явный true оставлен на случай
|
||||
// обратной потребности.
|
||||
const showIssuesRequirements = filter.show_issues_requirements === true;
|
||||
|
||||
// Собираем все project_hash для фильтрации
|
||||
let projectHashesToFilter: string[] = [filter.project_hash];
|
||||
@@ -706,8 +710,15 @@ export class GenerationService {
|
||||
issueHashesToFilter
|
||||
);
|
||||
|
||||
// Если задачные требования выключены, отфильтровываем stories с непустым issue_hash:
|
||||
// репозиторий выбирает по `project_hash IN (...) OR issue_hash IN (...)`, и stories,
|
||||
// привязанные к задачам, всё равно попадают через project_hash. Их нужно убрать здесь.
|
||||
const filteredStories = showIssuesRequirements
|
||||
? allStories
|
||||
: allStories.filter((story) => !story.issue_hash || story.issue_hash.trim() === '');
|
||||
|
||||
// Убираем дубликаты (на случай если одна история относится к нескольким проектам)
|
||||
const uniqueStories = allStories.filter(
|
||||
const uniqueStories = filteredStories.filter(
|
||||
(story, index, self) => index === self.findIndex((s) => s.story_hash === story.story_hash)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cooptypes",
|
||||
"type": "module",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -77,3 +77,8 @@ export const _ledger2 = {
|
||||
production: 'ledger2',
|
||||
testnet: 'ledger2',
|
||||
} as const
|
||||
|
||||
export const _apps = {
|
||||
production: 'apps',
|
||||
testnet: 'apps',
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
|
||||
/**
|
||||
* Авторизация не требуется — операция доброкачественная (TTL-очистка просроченных
|
||||
* superseded-записей). Любой может вызвать.
|
||||
*/
|
||||
export const authorizations = [] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'cleanup'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type ICleanup = Apps.ICleanup
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'expsub'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IExpsub = Apps.IExpsub
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Регистрация нового пакета в каталоге.
|
||||
*/
|
||||
export * as Regpackage from './regpackage'
|
||||
|
||||
/**
|
||||
* Передача владения пакетом другому аккаунту.
|
||||
*/
|
||||
export * as Transferpkg from './transferpkg'
|
||||
|
||||
/**
|
||||
* Публикация нового релиза с атомарным supersede предыдущего active в том же scope
|
||||
* и inline TTL-cleanup просроченных superseded.
|
||||
*/
|
||||
export * as Setrelease from './setrelease'
|
||||
|
||||
/**
|
||||
* Откат на предыдущую версию из окна retention (FR43).
|
||||
*/
|
||||
export * as Reactivate from './reactivate'
|
||||
|
||||
/**
|
||||
* Отзыв релиза (CVE / нарушение). Withdrawn записи не подпадают под TTL.
|
||||
*/
|
||||
export * as Withdraw from './withdraw'
|
||||
|
||||
/**
|
||||
* Ручная очистка просроченных superseded-записей (TTL 90 дней).
|
||||
* Доброкачественная операция — без авторизации.
|
||||
*/
|
||||
export * as Cleanup from './cleanup'
|
||||
|
||||
/**
|
||||
* Регистрация / продление подписки кооператива на пакет (idempotent по subscriber+package_id).
|
||||
*/
|
||||
export * as Regsub from './regsub'
|
||||
|
||||
/**
|
||||
* Деактивация подписки (active=false). Row не удаляется — нужен для аудита.
|
||||
*/
|
||||
export * as Expsub from './expsub'
|
||||
|
||||
/**
|
||||
* Первичная регистрация кооператива в каталоге.
|
||||
*/
|
||||
export * as Regcoop from './regcoop'
|
||||
|
||||
/**
|
||||
* Обновление полей кооператива, включая ротацию subnet-signing-key.
|
||||
*/
|
||||
export * as Setcoop from './setcoop'
|
||||
|
||||
/**
|
||||
* Пустышка для CDT-апгрейдов.
|
||||
* @private
|
||||
*/
|
||||
export * as Migrate from './migrate'
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
/**
|
||||
* Авторизация: apps@active — действие предназначено для CDT-апгрейдов.
|
||||
*/
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._contract }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'migrate'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IMigrate = Apps.IMigrate
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'reactivate'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IReactivate = Apps.IReactivate
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'regcoop'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IRegcoop = Apps.IRegcoop
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'regpackage'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IRegpackage = Apps.IRegpackage
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'regsub'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IRegsub = Apps.IRegsub
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'setcoop'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type ISetcoop = Apps.ISetcoop
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'setrelease'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type ISetrelease = Apps.ISetrelease
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'transferpkg'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type ITransferpkg = Apps.ITransferpkg
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Permissions from '../../../common/permissions'
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import { Actors } from '../../../common'
|
||||
|
||||
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
|
||||
|
||||
/**
|
||||
* Имя действия
|
||||
*/
|
||||
export const actionName = 'withdraw'
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*/
|
||||
export type IWithdraw = Apps.IWithdraw
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as contractNames from '../../common/names'
|
||||
|
||||
export * as Actions from './actions'
|
||||
export * as Tables from './tables'
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export * as Interfaces from '../../interfaces/apps'
|
||||
|
||||
export const contractName = contractNames._apps
|
||||
@@ -0,0 +1,19 @@
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import * as ContractNames from '../../../common/names'
|
||||
|
||||
/**
|
||||
* Имя таблицы
|
||||
*/
|
||||
export const tableName = 'coops'
|
||||
|
||||
/**
|
||||
* Таблица хранится в {@link ContractNames._apps | области памяти контракта}.
|
||||
*/
|
||||
export const scope = ContractNames._apps
|
||||
|
||||
/**
|
||||
* @interface
|
||||
* Кооперативы каталога: chain_id подсети + subnet-signing-key
|
||||
* (отдельный от eosio::active).
|
||||
*/
|
||||
export type ICoop = Apps.ICoop
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Реестр зарегистрированных пакетов каталога приложений.
|
||||
*/
|
||||
export * as Packages from './packages'
|
||||
|
||||
/**
|
||||
* Релизы пакетов: текущие active + recent superseded (TTL 90 дней).
|
||||
*/
|
||||
export * as Releases from './releases'
|
||||
|
||||
/**
|
||||
* Подписки кооперативов на пакеты в конкретных подсетях.
|
||||
*/
|
||||
export * as Subs from './subs'
|
||||
|
||||
/**
|
||||
* Кооперативы, подключённые к каталогу: chain_id подсети + subnet-signing-key.
|
||||
*/
|
||||
export * as Coops from './coops'
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import * as ContractNames from '../../../common/names'
|
||||
|
||||
/**
|
||||
* Имя таблицы
|
||||
*/
|
||||
export const tableName = 'packages'
|
||||
|
||||
/**
|
||||
* Таблица хранится в {@link ContractNames._apps | области памяти контракта}.
|
||||
*/
|
||||
export const scope = ContractNames._apps
|
||||
|
||||
/**
|
||||
* @interface
|
||||
* Реестр зарегистрированных пакетов каталога приложений.
|
||||
*/
|
||||
export type IPackage = Apps.IPackage
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import * as ContractNames from '../../../common/names'
|
||||
|
||||
/**
|
||||
* Имя таблицы
|
||||
*/
|
||||
export const tableName = 'releases'
|
||||
|
||||
/**
|
||||
* Таблица хранится в {@link ContractNames._apps | области памяти контракта}.
|
||||
*/
|
||||
export const scope = ContractNames._apps
|
||||
|
||||
/**
|
||||
* @interface
|
||||
* Релизы пакетов: текущие active + recent superseded (TTL 90 дней) + withdrawn (без TTL).
|
||||
*/
|
||||
export type IRelease = Apps.IRelease
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as Apps from '../../../interfaces/apps'
|
||||
import * as ContractNames from '../../../common/names'
|
||||
|
||||
/**
|
||||
* Имя таблицы
|
||||
*/
|
||||
export const tableName = 'subs'
|
||||
|
||||
/**
|
||||
* Таблица хранится в {@link ContractNames._apps | области памяти контракта}.
|
||||
*/
|
||||
export const scope = ContractNames._apps
|
||||
|
||||
/**
|
||||
* @interface
|
||||
* Подписки кооперативов на пакеты в конкретных подсетях.
|
||||
*/
|
||||
export type ISub = Apps.ISub
|
||||
@@ -85,3 +85,12 @@ export * as Ledger2Contract from './ledger2'
|
||||
* Смарт-контракт управления общими собраниями пайщиков
|
||||
*/
|
||||
export * as MeetContract from './meet'
|
||||
|
||||
/**
|
||||
* Смарт-контракт каталога приложений ВОСХОД (`apps-catalog`).
|
||||
*
|
||||
* Координационная плоскость: реестр пакетов, релизы (active/superseded/withdrawn),
|
||||
* подписки кооперативов на пакеты, реестр коопов с subnet-signing-key.
|
||||
* Деплоится на корневой KE-цепи и обслуживает несколько подсетей одновременно.
|
||||
*/
|
||||
export * as AppsContract from './apps'
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Generated from contracts/cpp/apps/apps.abi (eosio::abi/1.2)
|
||||
// Hand-written: eosio-abi2ts 1.2.2 не поддерживает optional-типы (`name?`, `checksum256?`),
|
||||
// которые используются в action `setcoop` для частичного обновления полей.
|
||||
|
||||
export type IName = string
|
||||
export type IChecksum256 = string
|
||||
export type IPublicKey = string
|
||||
export type ITimePointSec = string
|
||||
export type IUint64 = number | string
|
||||
|
||||
/**
|
||||
* Структура scope релиза. `kind` — `"all"` | `"subnet"` | `"canary"`.
|
||||
* Для `kind="all"` `targets` — пустой массив, для `subnet`/`canary` —
|
||||
* список chain_id'ов или имён подсетей.
|
||||
*/
|
||||
export interface IScope {
|
||||
kind: IName
|
||||
targets: IName[]
|
||||
}
|
||||
|
||||
// ── Tables ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Реестр зарегистрированных пакетов.
|
||||
*/
|
||||
export interface IPackage {
|
||||
package_id: IName
|
||||
package_name: string
|
||||
owner: IName
|
||||
compatible_subnets: IName[]
|
||||
last_active_version: string
|
||||
created_at: ITimePointSec
|
||||
updated_at: ITimePointSec
|
||||
}
|
||||
|
||||
/**
|
||||
* Релиз пакета. `status` — `"active"` | `"superseded"` | `"withdrawn"`.
|
||||
*/
|
||||
export interface IRelease {
|
||||
id: IUint64
|
||||
package_id: IName
|
||||
version: string
|
||||
scope: IScope
|
||||
status: IName
|
||||
published_at: ITimePointSec
|
||||
superseded_at: ITimePointSec
|
||||
moderated_by: IName
|
||||
tarball_sha256: IChecksum256
|
||||
meta: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписка кооператива на пакет в конкретной подсети.
|
||||
*/
|
||||
export interface ISub {
|
||||
id: IUint64
|
||||
coopname: IName
|
||||
package_id: IName
|
||||
chain_id: IChecksum256
|
||||
plan: IName
|
||||
active: boolean
|
||||
start_at: ITimePointSec
|
||||
end_at: ITimePointSec
|
||||
created_at: ITimePointSec
|
||||
updated_at: ITimePointSec
|
||||
}
|
||||
|
||||
/**
|
||||
* Кооператив, подключённый к каталогу. `signing_key` — отдельный subnet-signing-key,
|
||||
* не равный `eosio::active`.
|
||||
*/
|
||||
export interface ICoop {
|
||||
coopname: IName
|
||||
chain_id: IChecksum256
|
||||
subnet_label: IName
|
||||
signing_key: IPublicKey
|
||||
active: boolean
|
||||
registered_at: ITimePointSec
|
||||
key_rotated_at: ITimePointSec
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface IRegpackage {
|
||||
coopname: IName
|
||||
package_id: IName
|
||||
package_name: string
|
||||
owner: IName
|
||||
compatible_subnets: IName[]
|
||||
}
|
||||
|
||||
export interface ITransferpkg {
|
||||
coopname: IName
|
||||
package_id: IName
|
||||
new_owner: IName
|
||||
}
|
||||
|
||||
export interface ISetrelease {
|
||||
coopname: IName
|
||||
package_id: IName
|
||||
version: string
|
||||
scope: IScope
|
||||
tarball_sha256: IChecksum256
|
||||
moderated_by: IName
|
||||
meta: string
|
||||
}
|
||||
|
||||
export interface IReactivate {
|
||||
coopname: IName
|
||||
package_id: IName
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface IWithdraw {
|
||||
coopname: IName
|
||||
package_id: IName
|
||||
version: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface ICleanup {
|
||||
package_id: IName
|
||||
}
|
||||
|
||||
export interface IRegsub {
|
||||
coopname: IName
|
||||
subscriber: IName
|
||||
package_id: IName
|
||||
chain_id: IChecksum256
|
||||
plan: IName
|
||||
start_at: ITimePointSec
|
||||
end_at: ITimePointSec
|
||||
}
|
||||
|
||||
export interface IExpsub {
|
||||
coopname: IName
|
||||
subscriber: IName
|
||||
package_id: IName
|
||||
}
|
||||
|
||||
export interface IRegcoop {
|
||||
coopname: IName
|
||||
chain_id: IChecksum256
|
||||
subnet_label: IName
|
||||
signing_key: IPublicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Все поля кроме `coopname` опциональны — позволяют частично обновлять row в `coops`,
|
||||
* включая ротацию `signing_key`.
|
||||
*/
|
||||
export interface ISetcoop {
|
||||
coopname: IName
|
||||
chain_id?: IChecksum256
|
||||
subnet_label?: IName
|
||||
signing_key?: IPublicKey
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export interface IMigrate {}
|
||||
+4
-3
@@ -34,9 +34,10 @@ export function useContributorSearch(options: IContributorSearchOptions = {}) {
|
||||
project_hash: options.projectHash,
|
||||
display_name: searchQuery || undefined,
|
||||
},
|
||||
pagination: {
|
||||
options: {
|
||||
page: 1,
|
||||
limit: 50, // Ограничиваем количество для поиска
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,11 +66,11 @@ export function useContributorSearch(options: IContributorSearchOptions = {}) {
|
||||
filter: {
|
||||
project_hash: options.projectHash,
|
||||
},
|
||||
pagination: {
|
||||
options: {
|
||||
page: 1,
|
||||
limit: 10, // Предзагружаем 10 участников
|
||||
sortBy: '_created_at',
|
||||
descending: true, // Новые сначала
|
||||
sortOrder: 'DESC', // Новые сначала
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
+3
@@ -106,6 +106,9 @@ import type { IStory } from 'app/extensions/capital/entities/Story/model';
|
||||
const props = defineProps<{
|
||||
filter?: {
|
||||
project_hash?: string;
|
||||
/** Хеш задачи — артефакт привязывается к задаче и не аккумулируется на проекте/компоненте */
|
||||
issue_hash?: string;
|
||||
/** @deprecated legacy: используйте issue_hash */
|
||||
issue_id?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
+3
@@ -26,6 +26,9 @@ import type { IProjectPermissions } from 'app/extensions/capital/entities/Projec
|
||||
const props = withDefaults(defineProps<{
|
||||
filter?: {
|
||||
project_hash?: string;
|
||||
/** Хеш задачи — артефакт привязывается к задаче и не аккумулируется на проекте/компоненте */
|
||||
issue_hash?: string;
|
||||
/** @deprecated legacy: используйте issue_hash */
|
||||
issue_id?: string;
|
||||
};
|
||||
permissions?: IIssuePermissions | IProjectPermissions | null;
|
||||
|
||||
+1
-2
@@ -31,7 +31,7 @@ div(v-if="shouldShowTemporaryStub").q-pt-md
|
||||
.text-center.q-mb-lg
|
||||
.text-h6.q-mb-md Выбор ролей участия
|
||||
.text-body2.text-grey-7
|
||||
| По программе "Благорост" вы можете принимать участие в разных ролях. Выберите те, которые вам интересны:
|
||||
| Вы можете принимать участие в разных ролях. Выберите те, которые вам интересны:
|
||||
|
||||
// Карточки ролей
|
||||
.q-mt-xl
|
||||
@@ -312,7 +312,6 @@ const temporaryStubUsernames = [
|
||||
'ipesgnlxmnwx',
|
||||
'ndrlqjeptxhh',
|
||||
'hntppjjknmsu',
|
||||
'ant',
|
||||
'vvqamckynxod',
|
||||
'zxfevlujlica',
|
||||
'spnpcpshemqp',
|
||||
|
||||
+4
-4
@@ -77,11 +77,11 @@ const loadContributors = async () => {
|
||||
filter: {
|
||||
coopname: info.coopname,
|
||||
},
|
||||
pagination: {
|
||||
options: {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.rowsPerPage,
|
||||
sortBy: pagination.value.sortBy,
|
||||
descending: pagination.value.descending,
|
||||
sortOrder: pagination.value.descending ? 'DESC' : 'ASC',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -120,11 +120,11 @@ const reloadContributors = async () => {
|
||||
filter: {
|
||||
coopname: info.coopname,
|
||||
},
|
||||
pagination: {
|
||||
options: {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.rowsPerPage,
|
||||
sortBy: pagination.value.sortBy,
|
||||
descending: pagination.value.descending,
|
||||
sortOrder: pagination.value.descending ? 'DESC' : 'ASC',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -56,6 +56,31 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
|
||||
:commits='linkedGitCommits'
|
||||
)
|
||||
|
||||
q-expansion-item.q-px-md.q-mt-md(
|
||||
v-if='issue'
|
||||
icon='assignment'
|
||||
label='Требования к задаче'
|
||||
:caption='requirementsCaption'
|
||||
header-class='text-grey-7'
|
||||
dense-toggle
|
||||
default-opened
|
||||
)
|
||||
RequirementsListWidget(
|
||||
:filter='requirementsFilter'
|
||||
:max-items='50'
|
||||
:permissions='issue.permissions'
|
||||
)
|
||||
.row.justify-end.q-mt-sm(v-if='canCreateRequirement')
|
||||
q-btn(
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
color='primary'
|
||||
icon='add'
|
||||
label='Добавить требование'
|
||||
@click='openCreateRequirementDialog'
|
||||
)
|
||||
|
||||
q-expansion-item.q-px-md.q-mt-md(
|
||||
v-if='issue'
|
||||
icon='schedule'
|
||||
@@ -133,6 +158,31 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
|
||||
:commits='linkedGitCommits'
|
||||
)
|
||||
|
||||
q-expansion-item.q-px-md.q-mt-md(
|
||||
v-if='issue'
|
||||
icon='assignment'
|
||||
label='Требования к задаче'
|
||||
:caption='requirementsCaption'
|
||||
header-class='text-grey-7'
|
||||
dense-toggle
|
||||
default-opened
|
||||
)
|
||||
RequirementsListWidget(
|
||||
:filter='requirementsFilter'
|
||||
:max-items='50'
|
||||
:permissions='issue.permissions'
|
||||
)
|
||||
.row.justify-end.q-mt-sm(v-if='canCreateRequirement')
|
||||
q-btn(
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
color='primary'
|
||||
icon='add'
|
||||
label='Добавить требование'
|
||||
@click='openCreateRequirementDialog'
|
||||
)
|
||||
|
||||
q-expansion-item.q-px-md.q-mt-md(
|
||||
v-if='issue'
|
||||
icon='schedule'
|
||||
@@ -144,6 +194,12 @@ div.column.flex-1.min-h-0.min-w-0.no-wrap
|
||||
TimeEntriesWidget(
|
||||
:issue-hash='issue.issue_hash'
|
||||
)
|
||||
|
||||
CreateRequirementWithEditorDialog(
|
||||
ref='createRequirementDialog'
|
||||
:filter='createRequirementFilter'
|
||||
@success='handleRequirementCreated'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -163,6 +219,9 @@ import { useUpdateIssue } from 'app/extensions/capital/features/Issue/UpdateIssu
|
||||
import { IssueSidebarWidget, IssueLinkedGitCommitsWidget, TimeEntriesWidget } from 'app/extensions/capital/widgets';
|
||||
import { IssueTitleEditor } from 'app/extensions/capital/widgets/IssueTitleEditor';
|
||||
import { ProjectPathWidget } from 'app/extensions/capital/widgets/ProjectPathWidget';
|
||||
import { RequirementsListWidget } from 'app/extensions/capital/widgets/RequirementsListWidget';
|
||||
import { CreateRequirementWithEditorDialog } from 'app/extensions/capital/features/Story/CreateStory';
|
||||
import { useStoryStore, type IGetStoriesInput } from 'app/extensions/capital/entities/Story/model';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -215,6 +274,41 @@ const parentHash = computed(() => projectHash.value);
|
||||
|
||||
const linkedGitCommits = computed(() => issue.value?.linked_git_commits ?? []);
|
||||
|
||||
// Фильтр для виджета требований — story только этой задачи
|
||||
const requirementsFilter = computed<Partial<IGetStoriesInput['filter']>>(() => ({
|
||||
issue_hash: issue.value?.issue_hash ?? '',
|
||||
}));
|
||||
|
||||
// Фильтр для диалога создания — issue_hash + project_hash родителя
|
||||
const createRequirementFilter = computed(() => ({
|
||||
project_hash: projectHash.value,
|
||||
issue_hash: issue.value?.issue_hash ?? '',
|
||||
}));
|
||||
|
||||
// Право на создание требования к задаче
|
||||
const canCreateRequirement = computed((): boolean => {
|
||||
return Boolean(issue.value?.permissions?.can_create_requirement);
|
||||
});
|
||||
|
||||
// Подпись рядом с заголовком «Требования к задаче»
|
||||
const storyStore = useStoryStore();
|
||||
const requirementsCaption = computed(() => {
|
||||
const stored = storyStore.stories;
|
||||
const cur = issue.value?.issue_hash;
|
||||
if (!cur || !stored) return '';
|
||||
const count = stored.items.filter((s) => s.issue_hash === cur).length;
|
||||
if (count === 0) return 'пока пусто';
|
||||
return `${count} шт.`;
|
||||
});
|
||||
|
||||
const createRequirementDialog = ref<InstanceType<typeof CreateRequirementWithEditorDialog> | null>(null);
|
||||
const openCreateRequirementDialog = () => {
|
||||
createRequirementDialog.value?.openDialog();
|
||||
};
|
||||
const handleRequirementCreated = () => {
|
||||
// RequirementsListWidget сам подцепит новый story через store
|
||||
};
|
||||
|
||||
// Подпись рядом с заголовком «История рабочего времени» — отражает факт/план одной строкой.
|
||||
const worklogCaption = computed(() => {
|
||||
const fact = issue.value?.fact ?? 0;
|
||||
|
||||
+2
-1
@@ -89,13 +89,14 @@ import { api as IssueApi } from 'app/extensions/capital/entities/Issue/api';
|
||||
import { DeleteStoryButton } from 'app/extensions/capital/features/Story/DeleteStory';
|
||||
import { EditRequirementDialog } from 'app/extensions/capital/features/Story/EditRequirement';
|
||||
import type { IProjectPermissions } from 'app/extensions/capital/entities/Project/model';
|
||||
import type { IIssuePermissions } from 'app/extensions/capital/entities/Issue/model';
|
||||
import { EntityIdBadge } from 'src/shared/ui/EntityIdBadge';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
filter?: Partial<IGetStoriesInput['filter']>;
|
||||
maxItems?: number;
|
||||
permissions?: IProjectPermissions | null;
|
||||
permissions?: IProjectPermissions | IIssuePermissions | null;
|
||||
/** Имя маршрута карточки артефакта (project-requirement-detail / component-requirement-detail) */
|
||||
detailRouteName?: string;
|
||||
/** На странице артефактов корневого проекта — подпись и ссылка на компонент для чужих project_hash */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/desktop",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
|
||||
"productName": "Desktop App",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/docs",
|
||||
"type": "module",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.9.0",
|
||||
"description": "Документация для монорепозитория",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/factory",
|
||||
"type": "module",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"description": "Фабрика юридических документов кооператива",
|
||||
"author": "Alex Ant <chairman.voskhod@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "@coopenomics/inter",
|
||||
"type": "module",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"description": "Контракты и DI-токены для связи расширений контроллера без прямых зависимостей",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "migrator",
|
||||
"version": "2026.4.19-alpha-1",
|
||||
"version": "2026.4.30-alpha-1",
|
||||
"description": "Утилита миграции данных между версиями",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user