Merge branch 'dev' into reports

# Conflicts:
#	.gitignore
#	components/contracts/cpp/lib/consts.hpp
#	components/contracts/cpp/wallet/p.wal.depo.standard.yaml
#	components/contracts/standards-site/src/components/FocusBar.vue
#	components/cooptypes/src/common/names/index.ts
This commit is contained in:
coopops
2026-05-04 11:23:58 +00:00
110 changed files with 3995 additions and 143 deletions
+38
View File
@@ -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
+101
View File
@@ -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})"
+14 -4
View File
@@ -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: |
+206
View File
@@ -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})"
+16 -14
View File
@@ -78,26 +78,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
+3 -3
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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,
+3
View File
@@ -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 }
}
+37 -3
View File
@@ -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 }
}
+2 -2
View File
@@ -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'])
+1 -1
View File
@@ -118,7 +118,7 @@ export function storyFileRelativePath(
const id2 = storyRequirementIdPrefix2(storyRecordId, storyHash)
if (issue?.titleSlug) {
const ip = capitalIdPathPrefix(issue.id)
return `${basePath}/issues/${ip}-${issue.titleSlug}-requirements/${id2}-${slug}.md`
return `${basePath}/issue-requirements/${ip}-${issue.titleSlug}/${id2}-${slug}.md`
}
return `${basePath}/requirements/${id2}-${slug}.md`
}
+2
View File
@@ -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(
+2
View File
@@ -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,
})
}
+2
View File
@@ -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')
}
+78
View File
@@ -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"]
+71
View File
@@ -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` в корне проекта):
+182
View File
@@ -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`.
+29
View File
@@ -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
}
},
},
})
+8 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -26,7 +26,7 @@ docker run --name node -d -p 8888:8888 -p 9876:9876 -p 8080:8080 \
-v ~/testnet/config:/mnt/dev/config \
-v ~/testnet/wallet:/root/eosio-wallet \
-v $dir/../../contracts:/contracts \
dicoop/blockchain: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
+19 -2
View File
@@ -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 },
)
}
}
+43 -20
View File
@@ -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',
},
]
+4
View File
@@ -150,6 +150,10 @@ export default {
name: 'contributor',
code_permissions_to: ['contributor'],
},
{
name: 'apps',
code_permissions_to: ['apps'],
},
// {
// name: provider_chairman,
// },
+76
View File
@@ -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 -1
View File
@@ -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": {
+1 -1
View File
@@ -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
+1
View File
@@ -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)
# Опции для тестов
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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()
+75
View File
@@ -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 как идентификатор подсети.
+24
View File
@@ -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);
}
+253
View File
@@ -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;
});
}
}
+4 -1
View File
@@ -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
+44
View File
@@ -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"]
+111
View File
@@ -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`.
+91
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.4.19-alpha-1",
"version": "2026.4.30-alpha-1",
"private": true,
"type": "module",
"scripts": {
+325
View File
@@ -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
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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"
},
@@ -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;
}
@@ -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 -1
View File
@@ -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'
+160
View File
@@ -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 {}
@@ -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', // Новые сначала
},
});
@@ -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;
};
}>();
@@ -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;
@@ -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',
@@ -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;
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+4 -1
View File
@@ -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 -1
View File
@@ -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