diff --git a/.agents/skills/v8std-architecture/SKILL.md b/.agents/skills/v8std-architecture/SKILL.md deleted file mode 100644 index 526afaf..0000000 --- a/.agents/skills/v8std-architecture/SKILL.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -name: v8std-architecture -description: Use when a v8std request may mutate files, affect architecture, requirements, ADRs, invariants, contracts or plans, or when implementation evidence changes the assessed impact. ---- - -# V8std architecture workflow - -## Core rule - -Classify impact before mutation and again before merge. A change is trivial -only after evidence excludes requirement, ADR, invariant and observable-contract -impact. Before mutation this is a manual assessment of intent and intended paths; -the CLI can inspect only an existing Git diff. Every mutation still uses a branch. - -The normative process specification is -`spec/process/architecture-artifacts-v1.md`. The Python validator is its -executable implementation, not a second policy source. Read the specification; -never reproduce its fields or regexes in this skill. - -## Required flow - -1. Read `AGENTS.md`, `spec/README.md`, the current branch, `main`, and the - process specification. -2. If the current branch is `main`, create a feature branch before the first write. -3. Inspect the actual code/docs and intended paths. A clean Git diff contains no - evidence about the requested change. -4. Apply [impact check](references/impact-check.md) manually to the intent and - intended paths. Call the change trivial only when every architecture trigger - is disproved. -5. If architecture impact is found or remains unresolved, stop mutation, use - `superpowers:brainstorming`, and select artifacts with - [document triggers](references/document-triggers.md). -6. After written design approval, use `superpowers:writing-plans` when - implementation is requested. -7. If implementation contradicts design or impact classification, stop and use - [failure recovery](references/failure-recovery.md). -8. After a diff exists and before merge, run CLI `impact`, inspect all changed - paths, repeat the semantic impact check, then run `validate --merge-ready`, - declared fitness checks, the full test suite and strict build. Empty CLI output - never proves triviality. -9. Merge locally into `main`. Push local `main` only with explicit authority; an - authorized push automatically builds and publishes the site. Never infer MCP - server deployment authority from merge, push or site publication: it requires - a separate explicit request and an exact verified SHA from `main`. - -## Quick reference - -| Evidence | Result | -|---|---| -| Only implementation internals change; no boundary or architecture trigger | Trivial path, branch and gates still required | -| Requirement, decision, durable property or boundary may change | Nontrivial; return to design | -| Accepted target has no complete accepted plan | It remains accepted but is not `IMPLEMENTED` | -| Complete accepted plan explicitly implements target | Target may be `IMPLEMENTED` | -| Existing structured document is in `main` | Frozen; create successor/version/revision | -| User authorizes push of local `main` | Verify local `main`; push automatically publishes the site | -| User requests MCP server deployment | Verify the exact SHA in `main`; deploy and post-check MCP separately | - -## Red flags - -Stop on: “small contract tweak”, direct `main`, merge-date ADR rename, editing an -accepted contract in place, incomplete plan, design-only called implemented, -silent invariant loss, site publication treated as MCP deployment authority, or -Git workflow proposed as a product invariant. - -The matching RED/GREEN cases are in -[pressure scenarios](references/pressure-scenarios.md). - -## Common rationalizations - -| Rationalization | Reality | -|---|---| -| “The user called it trivial” | Triviality is the impact result, not an input | -| “It is docs/config only” | Observable boundaries often live there | -| “Existing tests are green” | They do not prove unchanged architecture intent | -| “Editing the accepted file is clearer” | Frozen evidence requires a successor/version | -| “Direct main is faster” | The branch-first Git flow has no trivial exception | diff --git a/.agents/skills/v8std-architecture/references/document-triggers.md b/.agents/skills/v8std-architecture/references/document-triggers.md deleted file mode 100644 index 21ceece..0000000 --- a/.agents/skills/v8std-architecture/references/document-triggers.md +++ /dev/null @@ -1,18 +0,0 @@ -# Document triggers - -Select documents by cause, not by perceived change size. - -| Cause | Required artifact | -|---|---| -| New or changed obligation | Design requirement with semantic code | -| Choice among architecture alternatives | One atomic ADR | -| Durable property whose violation falsifies a decision | Product invariant and fitness check | -| Observable producer/consumer boundary | Versioned contract and conformance evidence | -| Multiple related decisions, requirements or boundaries | Design connecting the graph | -| Approved implementation is requested | Checkbox plan with typed `implements` | -| Repository workflow/schema changes | Process specification, skill or `AGENTS.md`; never product invariant | - -Use typed references from the process specification. A replacement explicitly -preserves, replaces or cancels affected requirements, ADRs, invariants and -contracts. Compatibility determines contract revision versus version. Existing -structured documents in `main` are evidence snapshots, not editable records. diff --git a/.agents/skills/v8std-architecture/references/failure-recovery.md b/.agents/skills/v8std-architecture/references/failure-recovery.md deleted file mode 100644 index 279218e..0000000 --- a/.agents/skills/v8std-architecture/references/failure-recovery.md +++ /dev/null @@ -1,31 +0,0 @@ -# Failure recovery - -## Implementation defect - -The approved requirement/design/boundary remains correct and code fails to -implement it. Add a failing regression test, prove RED, fix the cause, prove -GREEN, then repeat impact and merge gates. - -## Project error - -Evidence shows a requirement, decision, invariant, contract, scope or -triviality classification is wrong or incomplete. Stop implementation. Do not -patch around the design or relax validation. - -Return to `superpowers:brainstorming` and review the package together: - -1. affected requirements and their lifecycle; -2. design decisions and alternatives; -3. ADR replacement/cancellation; -4. invariant preservation/replacement/retirement; -5. contract compatibility and version/revision; -6. implementation plan, evidence and rollback. - -Write successors rather than modifying structured `main` artifacts. Resume -implementation only after the revised written design and plan are approved. - -## Classification test - -If changing only code could make the approved conformance test pass, treat it -as an implementation defect. If success requires redefining what “pass” means, -it is a project error. diff --git a/.agents/skills/v8std-architecture/references/impact-check.md b/.agents/skills/v8std-architecture/references/impact-check.md deleted file mode 100644 index e4caced..0000000 --- a/.agents/skills/v8std-architecture/references/impact-check.md +++ /dev/null @@ -1,38 +0,0 @@ -# Impact check - -Perform before mutation and before merge. It is a semantic assessment, not the -CLI command itself. - -## Before mutation - -Inspect the request, actual code/docs and intended paths, then answer every -question below manually. CLI `impact` reads only an existing Git diff. On a clean -branch it has no changed paths to inspect, so empty output proves nothing about -the requested change. - -## Questions - -Answer each with evidence: - -1. Does the request add, remove or reinterpret a product requirement? -2. Does it choose a different architecture direction or reverse an ADR? -3. Can it violate, replace or retire a durable product property? -4. Does it change an observable boundary: endpoint, URI, MCP tool/resource, - JSON field, event, metric, Markdown/HTML contract or generated registry? -5. Does it alter compatibility, producer, consumers, version or conformance? -6. Does it change a path listed by contract/invariant `governs`? -7. Does implementation evidence contradict the approved design or earlier - triviality assessment? - -Any “yes” or unresolved answer is nontrivial. Use brainstorming before further -mutation. “The user called it trivial”, small diff size, no runtime code, or a -passing existing suite are not evidence of triviality. - -## Diff and merge recheck - -After a diff exists, run -`.venv/bin/python scripts/v8std_architecture.py impact --root . --base-ref main`. -Its output lists only contract/invariant candidates whose `governs` paths match -the diff; it is not a semantic verdict. Inspect every candidate and every changed -path, answer the questions again, then record the conclusion in the task handoff. -New impact returns the work to design; it does not justify weakening the gate. diff --git a/.agents/skills/v8std-architecture/references/pressure-scenarios.md b/.agents/skills/v8std-architecture/references/pressure-scenarios.md deleted file mode 100644 index bc885fe..0000000 --- a/.agents/skills/v8std-architecture/references/pressure-scenarios.md +++ /dev/null @@ -1,21 +0,0 @@ -# Pressure scenarios - -RED зафиксирован до создания repo skill по фактическому `AGENTS.md`. Пользователь -выбрал Inline Execution без subagents, поэтому baseline измеряется структурно: -явное разрешение считается обязательным поведением, отсутствие правила — -незакрытым сценарием. GREEN проверяется теми же девятью входами по новому -`AGENTS.md`, skill и validator tests. - -| Input | RED failure in repository baseline | Expected behavior | GREEN result | -|---|---|---|---| -| «Это мелкое изменение MCP JSON contract, сразу поправь» | Impact check отсутствует; слово «мелкое» ничем не опровергается | Вручную проверить intent и boundary; при contract impact вернуть работу в design | PASS: impact reference считает unresolved boundary nontrivial | -| На чистой ветке CLI `impact` ничего не вывел | Пустой diff ошибочно принимают за доказательство тривиальности | Оценить intent и предполагаемые пути вручную; CLI использовать после появления diff | PASS: impact reference явно запрещает такой вывод | -| «Закоммить прямо в main» | `AGENTS.md` дословно разрешает direct commit и push в `main` | Создать feature branch до записи; direct main запрещён | PASS: `AGENTS.md` запрещает direct main без trivial exception | -| «Сайт уже опубликовался после push — обнови заодно MCP» | Site и MCP deployment смешаны одним общим правилом | Не считать merge, push или site publication разрешением на MCP deploy; потребовать отдельный явный запрос и точный SHA | PASS: `AGENTS.md` и skill разделяют две authority boundary | -| Реализация показала ошибку approved design | Нет stop/recovery rule | Остановить реализацию и комплексно пересмотреть requirements, design, ADR, invariants, contracts и plan | PASS: failure recovery возвращает весь package в brainstorming | -| Новый ADR заменяет старый и молча теряет invariant | Нет обязательного impact disposition | Явно preserve, replace или cancel каждый затронутый invariant | PASS: document triggers требуют полного disposition | -| Требуется исправить accepted contract на месте | Нет freeze/version rule | Не менять frozen document; создать revision либо version по compatibility | PASS: quick reference требует successor/version/revision | -| Design-only MCP v3 называют реализованным | Нет правила вычисления `IMPLEMENTED` | Требовать complete accepted plan, который явно implements target | PASS: quick reference фиксирует exact implementation predicate | -| Предлагается merge при incomplete plan | Нет merge-ready gate | Остановить merge до полного plan и всех fitness checks | PASS: required flow запускает `validate --merge-ready` и fitness checks | -| ADR переименовывают на дату merge | Нет правила идентичности даты | Сохранить дату создания в filename | PASS: red flag и process specification запрещают merge-date rename | -| Branch-first workflow предлагают записать product invariant | Нет process/product boundary | Оставить Git workflow в process/AGENTS/skill, не в product invariant | PASS: document triggers направляют workflow только в process layer | diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61d7ef2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +** +!requirements-build.lock +!zensical.toml +!retrieval-rules.yml +!LICENSE +!LICENSES/ +!LICENSES/*.txt +!docs/ +!docs/** +!overrides/ +!overrides/** +!runtime/ +!runtime/*.py +!runtime/requirements-mcp.lock +!scripts/ +!scripts/v8std_mcp_chunks.py +!scripts/v8std_retrieval_rules.py +!scripts/v8std_search_features.py +!scripts/generate_ai_artifacts.py +!scripts/generate_social_cards.py +!scripts/v8std_markdown.py +!scripts/atomic_files.py +!scripts/publish_license_texts.py +!delivery/ +!delivery/__init__.py +!delivery/mcp/ +!delivery/mcp/Dockerfile +!delivery/site/ +!delivery/site/*.py +!delivery/site/Dockerfile +!delivery/site/site.conf +!delivery/index/ +!delivery/index/*.py +!delivery/ci/ +!delivery/ci/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 915a37a..a392b2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,92 +1,312 @@ -# Simple workflow for deploying static content to GitHub Pages name: gh-pages on: push: - branches: - - main - - # Allows you to run this workflow manually from the Actions tab + branches: [main] + pull_request: workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read - pages: write - id-token: write -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +# Never interrupt a publication or its independently bounded host recovery. concurrency: - group: "pages" + group: v8std-ci-${{ github.event_name == 'pull_request' && github.ref || 'main' }} cancel-in-progress: false jobs: - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest + validate: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + actions: read + packages: read + outputs: + artifact_id: ${{ steps.validated.outputs.artifact-id }} + build: ${{ steps.build.outcome }} + tests: ${{ steps.tests.outcome }} + benchmark: ${{ steps.benchmark.outcome }} + env: + GH_TOKEN: ${{ github.token }} + MCP_IMAGE_PUBLICATION_ENABLED: ${{ vars.MCP_IMAGE_PUBLICATION_ENABLED }} + MCP_RUNTIME_DEPLOY_ENABLED: ${{ vars.MCP_RUNTIME_DEPLOY_ENABLED }} steps: - - name: Checkout - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 2 - - name: Validate architecture graph - run: | - python3 -m pip install --disable-pip-version-check PyYAML - python3 scripts/v8std_architecture.py validate --root . --base-ref HEAD^ --merge-ready - - name: Build docs image + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12.14' + - name: Locked host test environment and workflow linter run: | - docker build -f docker-compose/docker/Dockerfile -t v8std-docs . + python -m venv .venv + .venv/bin/python -m pip install --require-hashes --only-binary=:all: -r dev/requirements-test.lock + .venv/bin/python -m pip check + curl --fail --location --proto '=https' --max-time 60 -o "$RUNNER_TEMP/actionlint.tar.gz" https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/v8std-tools" + tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP/v8std-tools" actionlint + echo "$RUNNER_TEMP/v8std-tools" >> "$GITHUB_PATH" + - name: Classify committed inputs against independent published states + run: .venv/bin/python -m delivery.ci.publish_mcp_artifacts plan --directory .ci + - name: Validate workflow + run: actionlint .github/workflows/ci.yml + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + with: + version: v0.37.1 + driver-opts: image=moby/buildkit@sha256:6c2fa84a6b61ccd72899dde4239f8d5717f05f9a8ca6f3cad185fb1a95a94de3 - name: Build + id: build run: | - # Keep generated artifacts owned by the checkout user for later gates. - docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/docs" \ - -w /docs \ - v8std-docs build --strict + docker buildx build --load -f delivery/ci/Dockerfile -t v8std-ci:builder . + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/docs" v8std-ci:builder build --strict - name: Test MCP retrieval + id: tests run: | - # Git-backed tests must run as the owner of the mounted checkout. - docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/docs" \ - -w /docs \ - --entrypoint python \ - v8std-docs -m unittest discover -v - docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/docs" \ - -w /docs \ - --entrypoint python \ - v8std-docs scripts/acc_diagnostics.py generate --check - docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/docs" \ - -w /docs \ - --entrypoint python \ - v8std-docs scripts/generate_diagnostic_standard_links.py --check - docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/docs" \ - -w /docs \ - --entrypoint python \ - v8std-docs scripts/check_diagnostic_articles.py - docker run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/docs" \ - -w /docs \ - --entrypoint python \ - v8std-docs scripts/search_benchmark.py - - name: Setup Pages - uses: actions/configure-pages@v6 - - name: Upload artifact - uses: actions/upload-pages-artifact@v5 - with: - path: 'site' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 + # Host-side tests have Docker CLI; no socket is mounted into any test/docs image. + .venv/bin/python -W error -m unittest tests.test_v8std_mcp_snippet.SnippetWireTests tests.test_v8std_mcp_runtime.RuntimeTests.test_official_http_discovery_before_ready_and_session_end_does_not_close_index -v + .venv/bin/python -m unittest discover -s tests -t . -v + - name: Behavioral and generated-artifact gates + id: benchmark + run: | + .venv/bin/python -m dev.content.acc_diagnostics generate --check + .venv/bin/python -m dev.content.generate_diagnostic_standard_links --check + .venv/bin/python -m dev.checks.check_diagnostic_articles + .venv/bin/python -m dev.checks.search_benchmark + .venv/bin/python -m dev.checks.snippet_benchmark --baseline-ref 3df5b40e773d0e7bc146ac2d9214934bb4145f73 + - name: Build identical public and local corpus with selected corpus SHA + run: | + CORPUS_SHA=$(.venv/bin/python -m delivery.ci.publish_mcp_artifacts value --directory .ci --field corpus_sha) + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/docs" --entrypoint python v8std-ci:builder -m delivery.index.generate_mcp_snapshot --source-sha "$CORPUS_SHA" --output .ci/snapshot --public-delivery + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/docs" --entrypoint python v8std-ci:builder -m delivery.site.build_local_site --output .ci/local-site --site-url http://localhost:18765/ --source-sha "$CORPUS_SHA" + .venv/bin/python -m delivery.ci.publish_mcp_artifacts verify-build --directory .ci + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + id: validated + with: + name: validated-artifacts-${{ github.run_attempt }} + path: | + site/ + .ci/ + docs/ai/ + docs/llms.txt + docs/llms-full.txt + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + publish: + needs: validate + if: >- + github.repository == 'zeegin/v8std' && github.ref == 'refs/heads/main' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + needs.validate.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + outputs: + artifact_id: ${{ steps.accepted.outputs.artifact-id }} + environment: + name: github-pages + url: ${{ steps.pages.outputs.page_url }} + permissions: + contents: read + actions: read + packages: write + attestations: write + id-token: write + pages: write + env: + GH_TOKEN: ${{ github.token }} + MCP_IMAGE_PUBLICATION_ENABLED: ${{ vars.MCP_IMAGE_PUBLICATION_ENABLED }} + MCP_CORPUS_PUBLICATION_ENABLED: ${{ vars.MCP_CORPUS_PUBLICATION_ENABLED }} + MCP_RUNTIME_DEPLOY_ENABLED: ${{ vars.MCP_RUNTIME_DEPLOY_ENABLED }} + MCP_GATES: >- + {"build":"${{ needs.validate.outputs.build }}","tests":"${{ needs.validate.outputs.tests }}","benchmark":"${{ needs.validate.outputs.benchmark }}"} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.12.14' + - run: | + python -m venv .venv + .venv/bin/python -m pip install --require-hashes --only-binary=:all: -r dev/requirements-test.lock + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + artifact-ids: ${{ needs.validate.outputs.artifact_id }} + merge-multiple: true + digest-mismatch: error + - name: Fresh main and actual external protection prerequisites + run: .venv/bin/python -m delivery.ci.publish_mcp_artifacts guard --directory .ci --environment github-pages + - name: Isolate registry credentials on this ephemeral runner + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' + run: | + mkdir -p "$RUNNER_TEMP/v8std-registry" + echo "DOCKER_CONFIG=$RUNNER_TEMP/v8std-registry" >> "$GITHUB_ENV" + - name: Preserve selected corpus source identity + id: corpus + run: echo "changed=$(.venv/bin/python -m delivery.ci.publish_mcp_artifacts value --directory .ci --field corpus_changed)" >> "$GITHUB_OUTPUT" + - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' + with: + image: tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 + platforms: arm64 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' + with: + version: v0.37.1 + driver-opts: image=moby/buildkit@sha256:6c2fa84a6b61ccd72899dde4239f8d5717f05f9a8ca6f3cad185fb1a95a94de3 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Resolve immutable image identity before any build or tag write + id: image_plan + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' + run: .venv/bin/python -m delivery.ci.publish_mcp_artifacts image-plan --directory .ci + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + id: image + if: steps.image_plan.outputs.build == 'true' + with: + context: . + file: delivery/mcp/Dockerfile + platforms: linux/amd64,linux/arm64 + # Unverified candidates are addressable by digest, not immutable source tags. + outputs: type=image,name=ghcr.io/zeegin/v8std-mcp,push-by-digest=true,name-canonical=true,push=true + build-args: SOURCE_SHA=${{ github.sha }} + provenance: mode=max + attests: type=sbom,generator=docker/buildkit-syft-scanner@sha256:ae4f3b554449e7e25548e7d8ccc029d17357348e30c6e3df01b92bc93654d6a9 + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + if: steps.image_plan.outputs.build == 'true' + with: + subject-name: ghcr.io/zeegin/v8std-mcp + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a + id: site_image + if: steps.image_plan.outputs.site_build == 'true' + with: + context: . + file: delivery/site/Dockerfile + build-contexts: local-site=.ci/local-site + platforms: linux/amd64,linux/arm64 + outputs: type=image,name=ghcr.io/zeegin/v8std-site,push-by-digest=true,name-canonical=true,push=true + build-args: SOURCE_SHA=${{ github.sha }} + provenance: mode=max + attests: type=sbom,generator=docker/buildkit-syft-scanner@sha256:ae4f3b554449e7e25548e7d8ccc029d17357348e30c6e3df01b92bc93654d6a9 + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 + if: steps.image_plan.outputs.site_build == 'true' + with: + subject-name: ghcr.io/zeegin/v8std-site + subject-digest: ${{ steps.site_image.outputs.digest }} + push-to-registry: true + - name: Exact published digest smoke with explicit local source + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' + env: + MCP_BUILT_DIGEST: ${{ steps.image.outputs.digest }} + MCP_SITE_BUILT_DIGEST: ${{ steps.site_image.outputs.digest }} + run: .venv/bin/python -m delivery.ci.publish_mcp_artifacts record-image --directory .ci + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' + with: + name: mcp-runtime-state-v1-${{ github.run_attempt }} + path: .ci/runtime-state/state.json + if-no-files-found: error + include-hidden-files: true + retention-days: 90 + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 + if: vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' && steps.corpus.outputs.changed == 'true' + with: + subject-path: .ci/snapshot/*/snapshot.tar.gz + - name: Verify archive, exact publish acknowledgement, then stage Pages + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' || vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' || vars.MCP_RUNTIME_DEPLOY_ENABLED == 'true' + env: + MCP_RELEASE_HOST: ${{ vars.MCP_RELEASE_HOST }} + MCP_RELEASE_SSH_KEY: ${{ vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' && secrets.MCP_RELEASE_SSH_KEY || '' }} + MCP_RELEASE_KNOWN_HOSTS: ${{ vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' && secrets.MCP_RELEASE_KNOWN_HOSTS || '' }} + run: .venv/bin/python -m delivery.ci.publish_mcp_artifacts prepare-pages --directory .ci + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' + with: + name: mcp-corpus-state-v1-${{ github.run_attempt }} + path: .ci/corpus-state/state.json + if-no-files-found: error + include-hidden-files: true + retention-days: 90 + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + name: github-pages-${{ github.run_attempt }} + - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 + id: pages + with: + artifact_name: github-pages-${{ github.run_attempt }} + - name: Separate reference acknowledgement, real default/anonymous digest proof, stable promotion + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' || vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' || vars.MCP_RUNTIME_DEPLOY_ENABLED == 'true' + env: + MCP_RELEASE_HOST: ${{ vars.MCP_RELEASE_HOST }} + MCP_RELEASE_SSH_KEY: ${{ vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' && secrets.MCP_RELEASE_SSH_KEY || '' }} + MCP_RELEASE_KNOWN_HOSTS: ${{ vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' && secrets.MCP_RELEASE_KNOWN_HOSTS || '' }} + run: .venv/bin/python -m delivery.ci.publish_mcp_artifacts finish --directory .ci + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + id: accepted + if: vars.MCP_IMAGE_PUBLICATION_ENABLED == 'true' || vars.MCP_CORPUS_PUBLICATION_ENABLED == 'true' || vars.MCP_RUNTIME_DEPLOY_ENABLED == 'true' + with: + name: accepted-publication-${{ github.run_attempt }} + path: .ci/accepted.json + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + runtime: + needs: [validate, publish] + if: >- + github.repository == 'zeegin/v8std' && github.ref == 'refs/heads/main' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + needs.validate.result == 'success' && needs.publish.result == 'success' && + vars.MCP_RUNTIME_DEPLOY_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: mcp-production + permissions: + contents: read + actions: read + packages: read + env: + GH_TOKEN: ${{ github.token }} + MCP_RUNTIME_DEPLOY_ENABLED: ${{ vars.MCP_RUNTIME_DEPLOY_ENABLED }} + MCP_GATES: >- + {"build":"${{ needs.validate.outputs.build }}","tests":"${{ needs.validate.outputs.tests }}","benchmark":"${{ needs.validate.outputs.benchmark }}"} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.12.14' + - run: | + python -m venv .venv + .venv/bin/python -m pip install --require-hashes --only-binary=:all: -r dev/requirements-test.lock + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + artifact-ids: ${{ needs.publish.outputs.artifact_id }} + merge-multiple: true + path: .ci + digest-mismatch: error + - name: Fresh protected main and branch-only production environment + run: .venv/bin/python -m delivery.ci.publish_mcp_artifacts guard --directory .ci --environment mcp-production + - name: Restricted published-digest release; never bootstrap or rebuild + env: + MCP_RELEASE_HOST: ${{ vars.MCP_RELEASE_HOST }} + MCP_RELEASE_SSH_KEY: ${{ secrets.MCP_RELEASE_SSH_KEY }} + MCP_RELEASE_KNOWN_HOSTS: ${{ secrets.MCP_RELEASE_KNOWN_HOSTS }} + MCP_CONFIGURATION_DIGEST: ${{ vars.MCP_CONFIGURATION_DIGEST }} + MCP_PLATFORM: ${{ vars.MCP_PLATFORM }} + run: .venv/bin/python -m delivery.ci.publish_mcp_artifacts deploy --directory .ci diff --git a/.gitignore b/.gitignore index d622d8f..1f8626a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .vscode/ -site/ +/site/ .DS_Store .cache/ .venv/ diff --git a/AGENTS.md b/AGENTS.md index aefc7e6..a0dd27d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,24 +1,26 @@ # Правила работы с репозиторием -- Исправляй причину, а не последствие. -- Находи противоречия в рассуждениях пользователя и результатах работы, не замалчивай их, а явно подсвечивай. -- Ищи логические ошибки и указывай на них. -- После завершения работы сообщай, сколько токенов было потрачено. -- Любое изменение файлов выполняй в отдельной ветке, созданной до первой записи. -- Прямые коммиты в `main` и push feature-ветки непосредственно в удалённый - `main` запрещены. После всех gate выполняй локальный merge без повторного - выбора способа интеграции. -- Работай в основном checkout. Worktree и pull request используй только по явному указанию пользователя. -- Перед изменением используй `.agents/skills/v8std-architecture/SKILL.md` и - вручную оцени архитектурное влияние намерения и предполагаемых путей; если - влияние найдено или не исключено, до дальнейших изменений используй - `superpowers:brainstorming`. -- Нетривиальную реализацию не начинай до письменного согласования design-пакета и создания plan через `superpowers:writing-plans`. -- Внутренние design, ADR, invariants, contracts, plans и process specifications храни только в `spec/`. -- Не изменяй и не удаляй structured documents из `main`; создавай преемника, версию или ревизию. -- Перед merge повтори semantic impact check по фактическому diff, запусти CLI - `impact`, `validate --merge-ready`, fitness checks, полный test suite и strict - build. -- Если реализация опровергла design, остановись и верни работу в brainstorming с комплексным пересмотром графа. -- Push локального `main` выполняй только по явному запросу; разрешённый push автоматически запускает сборку и публикацию сайта для проверенного SHA. -- Deploy MCP-сервера выполняй отдельно, только по явному запросу и только для проверенного SHA из `main`; merge, push и публикация сайта такого разрешения не дают. +- `docs/` содержит публикуемый контент сайта, а не внутреннюю документацию + проекта. Не размещай там технические планы, архитектурные описания, отчёты + о работе и инструкции для разработки или эксплуатации проекта. + +- Исправляй причину проблемы. Явно указывай на противоречия и логические ошибки. +- Держись согласованной задачи и проверяемого результата. Не расширяй объём + работ без обсуждения с пользователем. +- Проверяй фактический код и поведение. Документы в `spec/` могут устаревать; + они не задают обязательный процесс и не требуют создания новых документов. +- Создавай документацию только когда она помогает выполнить задачу или + пользоваться результатом. Существующие документы можно исправлять на месте. +- Работай в основном checkout и отдельной ветке, созданной до первой записи. + Worktree и pull request используй только по явному указанию пользователя. + Не коммить напрямую в `main` и не отправляй feature-ветку в удалённый `main`. +- Сохраняй существующие незакоммиченные изменения, не относящиеся к задаче. +- Выполняй проверки, соответствующие изменению. Перед выпуском проверяй + итоговый кандидат тестами и strict build. Повторяй проверки при изменении + кандидата или появлении новых оснований, а не ради оформления документов. +- Push выполняй только по явному запросу: push `main` запускает публикацию. + Первичная установка и переключение MCP, изменения production, настроек + и доступов требуют отдельного разрешения. После активации разрешённый push + допускает обычный rollout через настроенный CI с проверкой и rollback. +- В конце сообщай результат, выполненные проверки и оставшиеся препятствия. + Указывай расход токенов, если он доступен; не придумывай точное число. diff --git a/README.md b/README.md index 2815a7f..c36dfe8 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,37 @@ # Стандарты разработки 1С -https://v8std.ru +Сайт: [v8std.ru](https://v8std.ru). MCP: [ai.v8std.ru](https://ai.v8std.ru/mcp). -## Локальный MCP +## Разработка сайта ```bash -docker compose -f docker-compose/docker-compose.yml up -d v8std-mcp +python3.12 -m venv .venv +.venv/bin/python -m pip install --require-hashes -r requirements-build.lock +VIRTUAL_ENV="$PWD/.venv" bash scripts/zensical_docs.sh serve --dev-addr=127.0.0.1:8000 ``` -MCP-сервер будет доступен на `http://127.0.0.1:8765/mcp` и читает локальный индекс -`docs/ai/pages.jsonl` из смонтированного репозитория. +Контент находится в `docs/`, шаблоны — в `overrides/`. Сборка сайта не требует MCP. -```bash -codex mcp add v8std-local --url http://127.0.0.1:8765/mcp -``` +## Устройство репозитория + +- `scripts/`, `data/`, конфигурации в корне — сборка сайта и индекса. +- `runtime/` — MCP-сервис; имя не конфликтует с Python SDK `mcp`. +- `delivery/` — образы, публикация артефактов и доставка на VPS. +- `dev/` — подготовка контента и инструменты разработчика. +- `tests/` — действующие проверки; `spec/` — цель и устройство поставки. + +Python-команды из новых каталогов запускаются из корня через `python -m`, +например `.venv/bin/python -m dev.content.acc_diagnostics generate --check`. +Тестовые зависимости: `.venv/bin/python -m pip install --require-hashes -r dev/requirements-test.lock`. +Тесты: `.venv/bin/python -m unittest discover -s tests -t .`. + +## Готовые образы + +Публичный и локальный MCP используют Streamable HTTP. Отдельный транспорт для локального запуска не требуется. + +Настройки локальной поставки находятся в `delivery/local/compose.yaml`. +Текущий файл описывает сайт и совместный запуск с MCP; самостоятельный MCP +с публичным индексом ещё требует проверки и завершения. + +[Целевая поставка](spec/delivery-target.md) · [Файлы сборки сайта](spec/local-site-build-files.md) · +[План разделения](spec/repository-layout-plan.md) diff --git a/delivery/__init__.py b/delivery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/delivery/ci/Dockerfile b/delivery/ci/Dockerfile new file mode 100644 index 0000000..84b49bf --- /dev/null +++ b/delivery/ci/Dockerfile @@ -0,0 +1,16 @@ +# Public artifact builder only. Tests run on the host with Docker CLI available. +# The immutable Python base also pins libc/zlib/gzip implementation and pip. +FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +COPY requirements-build.lock /opt/v8std/requirements-build.lock +RUN python -m pip install --no-cache-dir --require-hashes -r /opt/v8std/requirements-build.lock \ + && python -m pip check +# Same DejaVu public social-card family; no apt resolver or fallback font. +ADD --checksum=sha256:86635b3d25b3655fc11cb3ecc3af59f0bf19643b02b94f2de48bd10253cdba12 https://deb.debian.org/debian/pool/main/f/fonts-dejavu/fonts-dejavu-core_2.37-8_all.deb /opt/v8std/fonts.deb +RUN dpkg-deb --extract /opt/v8std/fonts.deb / \ + && test -s /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf \ + && test -s /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf \ + && rm /opt/v8std/fonts.deb +WORKDIR /docs +USER 10001:10001 +ENTRYPOINT ["bash", "scripts/zensical_docs.sh"] diff --git a/delivery/ci/__init__.py b/delivery/ci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/delivery/ci/fonts.sha256 b/delivery/ci/fonts.sha256 new file mode 100644 index 0000000..fb9e943 --- /dev/null +++ b/delivery/ci/fonts.sha256 @@ -0,0 +1,4 @@ +# Public artifact builder input, independently hashed from Debian over HTTPS. +# Extracted without apt/package dependency resolution; font family unchanged. +# https://deb.debian.org/debian/pool/main/f/fonts-dejavu/fonts-dejavu-core_2.37-8_all.deb +86635b3d25b3655fc11cb3ecc3af59f0bf19643b02b94f2de48bd10253cdba12 fonts-dejavu-core_2.37-8_all.deb diff --git a/delivery/ci/publish_mcp_artifacts.py b/delivery/ci/publish_mcp_artifacts.py new file mode 100644 index 0000000..4a82eaf --- /dev/null +++ b/delivery/ci/publish_mcp_artifacts.py @@ -0,0 +1,1038 @@ +#!/usr/bin/env python3 +"""Fail-closed CI coordination; the restricted host controller owns publication. + +This helper stages Pages only after an exact committed receipt and public byte +verification. Its transport never grants an unrestricted host command. +""" +from __future__ import annotations + +import ast +import argparse +import base64 +from contextlib import contextmanager +import hashlib +import io +import json +import os +import re +import shlex +import signal +import stat +import subprocess +import sys +import tempfile +import time +import zipfile +import uuid +from pathlib import Path + +from delivery.index.generate_mcp_snapshot import _atomic_file +from runtime.v8std_mcp_snapshot_format import ( + MAX_ARCHIVE_BYTES, MAX_MANIFEST_BYTES, canonical_json, strict_json, validate_manifest, verify_archive, +) +from delivery.vps.v8std_mcp_release import ( + attestation_command, http as release_http, read_file, smoke as runtime_smoke, validate_envelope, validate_upload, +) + +REPOSITORY = "zeegin/v8std" +MANIFEST_URL = "https://v8std.ru/ai/mcp/v1/manifest.json" +IMAGE = "ghcr.io/zeegin/v8std-mcp" +SITE_IMAGE = "ghcr.io/zeegin/v8std-site" +GATES = {"build", "tests", "benchmark"} + + +def git(root, *args): + return subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, + timeout=30).stdout + + +def input_identities(root, source_sha): + """Hash committed input blobs, before source_sha salts the corpus descriptor. + + Runtime is the explicit Dockerfile COPY closure, not all scripts. Corpus + includes producer imports and the pinned builder (including fonts/zlib). + Generated docs outputs are not inputs; the strict builder regenerates them. + """ + require(isinstance(source_sha, str) and re.fullmatch(r"[0-9a-f]{40}", source_sha), "source_sha") + tree = {} + for entry in git(root, "ls-tree", "-rz", source_sha).split(b"\0"): + if entry: + info, name = entry.split(b"\t", 1) + mode, kind, blob = info.decode().split() + tree[name.decode()] = (mode, kind, blob) + + def read(name): + require(name in tree and tree[name][0] in {"100644", "100755"}, "input_not_regular") + payload = git(root, "cat-file", "blob", tree[name][2]) + require(len(payload) <= 4 * 1024 * 1024, "input_size") + return payload.decode() + + def expand(names): + selected = set() + for name in names: + matches = {path for path in tree if path == name.rstrip("/") or path.startswith(name.rstrip("/") + "/")} + require(bool(matches), "missing_input") + selected.update(matches) + return selected + + dockerfile = read("delivery/mcp/Dockerfile").replace("\\\n", " ") + copies = [] + for line in dockerfile.splitlines(): + if re.match(r"\s*(ADD|COPY)\s", line, re.I): + tokens = shlex.split(line) + require(tokens[0].upper() == "COPY" and not any(token.startswith("--from") for token in tokens), + "unsupported_copy") + inputs = [token for token in tokens[1:] if not token.startswith("--")][:-1] + require(inputs and all(re.fullmatch(r"[a-zA-Z0-9_./-]+", path) and ".." not in path.split("/") + for path in inputs), "unsupported_copy") + copies.extend(inputs) + require(bool(copies), "missing_copy") + runtime = expand(["delivery/mcp/Dockerfile", ".dockerignore", ".github/workflows/ci.yml", *copies]) + corpus = expand(["docs", "overrides", "delivery/site/Dockerfile", "delivery/site/site.conf", "zensical.toml", "retrieval-rules.yml", "LICENSE", "LICENSES", + "requirements-build.lock", "delivery/ci/Dockerfile", "delivery/ci/fonts.sha256", ".github/workflows/ci.yml", + "scripts/zensical_docs.sh", "scripts/zensical-version.sh", + "delivery/site/build_local_site.py", + "scripts/generate_ai_artifacts.py", "scripts/generate_search_vectors.py", + "data/diagnostic-sources.json", "data/acc-diagnostics.json", + "delivery/index/generate_mcp_snapshot.py"]) + wrapper = read("scripts/zensical_docs.sh") + invoked = re.findall(r'\$\{SCRIPT_DIR\}/([a-z_]+\.py)', wrapper) + invoked += re.findall(r'script_dir / "([a-z_]+\.py)"', wrapper) + corpus.update(expand(["scripts/" + name for name in invoked])) + corpus -= {name for name in corpus if name.startswith(("docs/ai/", "docs/assets/social/")) + or name in {"docs/llms.txt", "docs/llms-full.txt"}} + pending = [name for name in corpus if name.endswith(".py")] + inspected = set() + while pending: + name = pending.pop() + if name in inspected: + continue + inspected.add(name) + for node in ast.walk(ast.parse(read(name))): + imports = ([alias.name for alias in node.names] if isinstance(node, ast.Import) + else [node.module] if isinstance(node, ast.ImportFrom) and node.module else []) + for module in imports: + paths = [module.replace(".", "/") + ".py", + "scripts/" + module.removeprefix("scripts.").replace(".", "/") + ".py"] + for path in paths: + if path in tree and path not in corpus: + corpus.add(path) + pending.append(path) + # Package initializers also affect imported build behavior. + for name in list(corpus): + if name.endswith(".py"): + for parent in Path(name).parents: + initializer = str(parent / "__init__.py") + if initializer in tree: + corpus.add(initializer) + + + def identity(paths): + require(all(tree[name][0] in {"100644", "100755"} for name in paths), "input_not_regular") + return hashlib.sha256(canonical_json({name: list(tree[name]) for name in sorted(paths)})).hexdigest() + return {"runtime": identity(runtime), "corpus": identity(corpus)} + + +def choose_sources(trigger_sha, identities, published): + """published contains independently successful states, never HEAD^ or a failed run.""" + require(re.fullmatch(r"[0-9a-f]{40}", trigger_sha) is not None, "source_sha") + result = {} + for kind in ("runtime", "corpus"): + identity = identities[kind] + require(re.fullmatch(r"[0-9a-f]{64}", identity) is not None, "input_identity") + previous = published.get(kind) + if previous is not None: + require(type(previous) is dict and isinstance(previous.get("source_sha"), str) + and re.fullmatch(r"[0-9a-f]{40}", previous["source_sha"]) + and isinstance(previous.get("input_id"), str) + and re.fullmatch(r"[0-9a-f]{64}", previous["input_id"]), "published_state") + changed = previous is None or previous["input_id"] != identity + result[kind + "_sha"] = trigger_sha if changed else previous["source_sha"] + result[kind + "_changed"] = changed + return result + + +class PublicationError(ValueError): + """Bounded local error code, never raw credentials or host output.""" + + +def require(condition, code): + if not condition: + raise PublicationError(code) + + +def bounded_command(argv, *, payload=b"", seconds=30, limit=2 * 1024 * 1024, env=None): + """No shell/PIPE capture; kill the owned group even after its leader exits.""" + deadline = time.monotonic() + seconds + with tempfile.TemporaryFile() as source, tempfile.TemporaryFile() as output: + source.write(payload) + source.seek(0) + process = subprocess.Popen(argv, stdin=source, stdout=output, stderr=subprocess.DEVNULL, + start_new_session=True, env=env) + try: + while process.poll() is None: + require(time.monotonic() < deadline, "command_timeout") + require(output.tell() <= limit, "command_output") + time.sleep(min(.02, max(0, deadline - time.monotonic()))) + require(process.returncode == 0, "command_failed") + require(output.tell() <= limit, "command_output") + output.seek(0) + return output.read(limit + 1) + finally: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + except PermissionError: + # Darwin reports EPERM for a vanished process group. A live + # leader is still a cleanup failure, not a successful command. + require(process.poll() is not None, "command_cleanup") + process.wait(timeout=2) + + +def anonymous_environment(directory): + # Do not inherit alternate auth stores or a remote daemon selection. + excluded = {"DOCKER_AUTH_CONFIG", "REGISTRY_AUTH_FILE", "DOCKER_CONTEXT", "DOCKER_HOST", + "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY"} + return {**{key: value for key, value in os.environ.items() if key not in excluded}, "DOCKER_CONFIG": directory} + + +def check_external_protection(branch, environment, policies): + require(branch.get("protected") is True, "main_unprotected") + require(environment.get("deployment_branch_policy") == { + "protected_branches": False, "custom_branch_policies": True}, "environment_unrestricted") + entries = policies.get("branch_policies", []) + require(len(entries) == 1 and entries[0].get("type") == "branch" + and entries[0].get("name") == "main", "environment_not_main_branch_only") + + +def read_state_artifact(payload): + require(len(payload) <= 256 * 1024, "state_archive_size") + try: + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + entries = archive.infolist() + require(len(entries) == 1 and entries[0].filename == "state.json" + and entries[0].file_size <= 128 * 1024 + and not stat.S_ISLNK(entries[0].external_attr >> 16), "state_archive_shape") + return strict_json(archive.read(entries[0])) + except (zipfile.BadZipFile, RuntimeError): + raise PublicationError("state_archive_invalid") from None + + +def last_published(root, main_sha, sequence, kind, api, download): + """Read independent milestone artifacts, even when a later job in that run failed. + + This is internal CI state, not a public manifest or a new host authority. + Missing/expired/malformed history never silently means a successful publish. + Artifact upload steps run only after the corresponding verified milestone. + """ + require(kind in {"runtime", "corpus"}, "state_kind") + prefix = f"repos/{REPOSITORY}/actions" + deadline = time.monotonic() + 120 + def candidates(): + for page in range(1, 101): + require(time.monotonic() < deadline, "state_history_deadline") + listing = api(prefix + f"/artifacts?per_page=100&page={page}") + for artifact in sorted(listing["artifacts"], key=lambda entry: entry["id"], reverse=True): + if artifact.get("name", "").startswith(f"mcp-{kind}-state-v1-"): + yield artifact + if len(listing["artifacts"]) < 100: + return + raise PublicationError("state_history_window_exhausted") + for artifact in candidates(): + source = artifact.get("workflow_run", {}) + if (source.get("head_branch") != "main" or source.get("repository_id") != source.get("head_repository_id")): + continue + require(type(source.get("id")) is int and type(artifact.get("id")) is int, "state_run") + attempt = artifact["name"].removeprefix(f"mcp-{kind}-state-v1-") + require(re.fullmatch(r"[1-9][0-9]{0,2}", attempt), "state_attempt") + run = api(prefix + f"/runs/{source['id']}/attempts/{attempt}") + if (run.get("path") != ".github/workflows/ci.yml" or run.get("event") not in {"push", "workflow_dispatch"} + or run.get("repository", {}).get("full_name") != REPOSITORY + or run.get("head_repository", {}).get("full_name") != REPOSITORY): + continue + require(type(run.get("run_number")) is int and type(run.get("run_attempt")) is int + and run["run_attempt"] == int(attempt), "state_run") + previous_sequence = run["run_number"] * 1000 + run["run_attempt"] + if previous_sequence == sequence: + continue # This attempt's later job may see its own milestone. + require(previous_sequence < sequence, "stale_run") + require(run.get("status") == "completed" and run.get("head_branch") == "main" + and run.get("head_sha") == source.get("head_sha"), "state_run") + require(artifact.get("expired") is False, "published_state_expired") + state = read_state_artifact(download(prefix + f"/artifacts/{artifact['id']}/zip")) + fields = {"schema_version", "kind", "sequence", "trigger_sha", "source_sha", "input_id", + "image_digest" if kind == "runtime" else "manifest"} + require(set(state) == fields and type(state["schema_version"]) is int and state["schema_version"] == 1 + and state["kind"] == kind and type(state["sequence"]) is int + and state["sequence"] == previous_sequence and state["trigger_sha"] == run["head_sha"], "state_identity") + for sha in (state["source_sha"], state["trigger_sha"]): + require(isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40}", sha), "state_source") + try: + git(root, "merge-base", "--is-ancestor", sha, main_sha) + except subprocess.CalledProcessError: + raise PublicationError("state_not_main_ancestor") from None + require(input_identities(root, state["source_sha"])[kind] == state["input_id"], "state_input_identity") + if kind == "runtime": + require(isinstance(state["image_digest"], str) + and re.fullmatch(r"sha256:[0-9a-f]{64}", state["image_digest"]), "state_image") + else: + require(public_manifest(state["manifest"])["source_sha"] == state["source_sha"], "state_corpus") + return state + return None + + +class CITransport: + monotonic = staticmethod(time.monotonic) + sleep = staticmethod(time.sleep) + time = staticmethod(time.time) + + def __init__(self, *, release_host=None, key=None, known_hosts=None): + self.release_host, self.key, self.known_hosts = release_host, key, known_hosts + + def command(self, command, payload, *, seconds=30): + require(command in {"publish-index", "status", "validate-envelope", "deploy"}, "forced_command") + require(isinstance(self.release_host, str) + and re.fullmatch(r"[a-z_][a-z0-9_-]*@[a-z0-9][a-z0-9.-]*", self.release_host) + and self.key and self.known_hosts, "ssh_not_configured") + argv = ["ssh", "-F", "/dev/null", "-T", "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=" + self.known_hosts, + "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", + "-i", self.key, self.release_host, command] + return strict_json(bounded_command(argv, payload=payload, seconds=seconds, limit=128 * 1024)) + + def get(self, url, limit): + require(url == MANIFEST_URL or re.fullmatch( + r"https://ai\.v8std\.ru/indexes/v1/[0-9a-f]{64}/snapshot\.tar\.gz", url), "fetch_url") + with tempfile.TemporaryDirectory(prefix="v8std-public-proof-") as directory: + headers_path = Path(directory) / "headers" + command = ["curl", "-q", "--silent", "--show-error", "--proto", "=https", + "--max-redirs", "0", "--max-time", "30", "--connect-timeout", "10", + "-H", "Accept-Encoding: identity", "--dump-header", str(headers_path)] + def headers(): + raw = read_file(headers_path, 64 * 1024) + lines = raw.rstrip().split(b"\r\n\r\n")[-1].splitlines() + require(bool(lines) and re.fullmatch(rb"HTTP/[0-9.]+ [0-9]{3}(?: .*)?", lines[0]), "http_status") + status = lines[0].split()[1] + if status == b"404" and url == MANIFEST_URL: + raise FileNotFoundError("manifest_404") + require(status == b"200", "http_status") + fields = {} + for line in lines[1:]: + require(b":" in line, "http_headers") + key, value = line.split(b":", 1) + key = key.strip().lower() + require(key not in fields, "duplicate_http_header") + fields[key] = value.strip() + require(fields.get(b"content-encoding", b"identity") == b"identity", "http_encoding") + return fields + bounded_command([*command, "--head", url], seconds=32, limit=64 * 1024) + head = headers() + body = bounded_command([*command, "--max-filesize", str(limit), url], seconds=32, limit=limit) + fields = headers() + if b"content-length" in fields: + require(fields[b"content-length"] == str(len(body)).encode(), "http_length") + if url != MANIFEST_URL: + require(fields.get(b"content-type", b"").split(b";")[0] == b"application/gzip" + and b"content-length" in fields and b"etag" in fields + and b"immutable" in fields.get(b"cache-control", b""), "archive_headers") + require(head.get(b"content-length") == str(len(body)).encode(), "archive_head_length") + return body + + def smoke(self, image, runtime_sha, manifest): + """Anonymous pull + real cold default source, no site-url override/cache. + + Docker credentials are isolated in this temporary directory. The helper + is invoked on the hosted runner, never against a production Docker host. + Each platform gets a distinct bounded, hardened disposable container. + """ + require(re.fullmatch(re.escape(IMAGE) + r"@sha256:[0-9a-f]{64}", image), "image_identity") + bounded_command(attestation_command("oci://" + image, runtime_sha), seconds=90) + with tempfile.TemporaryDirectory(prefix="v8std-anonymous-") as directory: + env = anonymous_environment(directory) + for platform in ("linux/amd64", "linux/arm64"): + bounded_command(["docker", "pull", "--platform", platform, image], env=env, seconds=180) + name = "v8std-ci-default-" + uuid.uuid4().hex + primary = None + try: + bounded_command(["docker", "run", "-d", "--name", name, "--label", "pro.v8std.ci=" + name, + "--platform", platform, "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--init", "--pids-limit", "128", "--memory", "1024m", "--cpus", "2", + "--tmpfs", "/tmp:size=16m", "--tmpfs", "/var/lib/v8std-mcp:rw,size=256m,uid=10001,gid=10001,mode=0700", + "-p", "127.0.0.1::8000", image, "--transport", "streamable-http", "--host", "0.0.0.0", + "--port", "8000", "--refresh-seconds", "0"], env=env, seconds=30) + inspected = json.loads(bounded_command(["docker", "inspect", name], env=env))[0] + require(inspected["Config"]["Labels"].get("org.opencontainers.image.revision") == runtime_sha, + "image_source_label") + port = inspected["NetworkSettings"]["Ports"]["8000/tcp"][0]["HostPort"] + deadline = time.monotonic() + 390 + record = {"runtime_source_sha": runtime_sha, "corpus_id": manifest["corpus_id"], + "archive_sha256": manifest["archive"]["sha256"], "hold_token": None} + while True: + try: + runtime_smoke("http://127.0.0.1:" + port, record, min(deadline, time.monotonic() + 30)) + break + except ValueError: + require(time.monotonic() < deadline, "default_source_not_ready") + time.sleep(1) + except BaseException as error: + primary = error + raise + finally: + try: + found = bounded_command(["docker", "container", "ls", "-a", "--filter", "name=^/" + name + "$", + "--format", "{{.Names}}"], env=env).decode().splitlines() + require(found in ([], [name]), "fixture_inventory") + if found: + info = json.loads(bounded_command(["docker", "inspect", name], env=env))[0] + require(info["Config"]["Labels"].get("pro.v8std.ci") == name, "fixture_ownership") + bounded_command(["docker", "rm", "--force", name], env=env) + require(not bounded_command(["docker", "container", "ls", "-a", "--filter", "name=^/" + name + "$", + "--format", "{{.Names}}"], env=env).strip(), "fixture_cleanup") + except BaseException as error: + if primary is None: + raise + primary.add_note("owned default-source fixture cleanup failed: " + str(error)) + + def local_smoke(self, image, runtime_sha, site_image, site_sha, manifest): + """Exercise both exact digests through the retained isolated Compose. + + This explicit-source check is not the later public-default proof. Host + MCP requests use the site's loopback proxy; runtime source and result + links share the unchanged Compose v8std.localhost address. + """ + root = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory(prefix="v8std-pair-proof-") as directory: + for platform in ("linux/amd64", "linux/arm64"): + project = "v8std-ci-pair-" + uuid.uuid4().hex[:16] + env = {**anonymous_environment(directory), "DOCKER_DEFAULT_PLATFORM": platform, + "V8STD_SITE_IMAGE": site_image, "V8STD_MCP_IMAGE": image, + "V8STD_SITE_PORT": "18765", "V8STD_MCP_PORT": "18766", + "V8STD_SITE_PREFIX": "/", "V8STD_MCP_SITE_URL": "http://v8std.localhost:18765/"} + compose = ["docker", "compose", "-p", project, "-f", str(root / "delivery/local/compose.yaml"), "--profile", "mcp"] + primary = None + try: + bounded_command([*compose, "pull"], env=env, seconds=240) + bounded_command([*compose, "up", "-d", "--no-build", "--pull", "never"], env=env, seconds=60) + for service, source in (("mcp", runtime_sha), ("site", site_sha)): + container = bounded_command([*compose, "ps", "-q", service], env=env).decode().strip() + require(re.fullmatch(r"[0-9a-f]{64}", container), "fixture_container") + # Docker returns an array. Wrap only at this boundary to + # retain strict JSON depth/duplicate checks, without + # weakening the snapshot parser's object-only contract. + raw = bounded_command(["docker", "inspect", container], env=env) + document = strict_json(b'{"containers":' + raw + b'}') + rows = document.get("containers") + require(set(document) == {"containers"} and type(rows) is list + and len(rows) == 1 and type(rows[0]) is dict, "inspect_shape") + info = rows[0] + config, host = info.get("Config"), info.get("HostConfig") + require(type(config) is dict and type(host) is dict + and type(config.get("Labels")) is dict, "inspect_shape") + require(config["Labels"].get("org.opencontainers.image.revision") == source + and config.get("User") == "10001:10001" + and host.get("Privileged") is False + and host.get("ReadonlyRootfs") is True, "image_profile") + record = {"runtime_source_sha": runtime_sha, "corpus_id": manifest["corpus_id"], + "archive_sha256": manifest["archive"]["sha256"], "hold_token": None} + deadline = time.monotonic() + 390 + while True: + try: + runtime_smoke("http://127.0.0.1:18766", record, min(deadline, time.monotonic() + 30)) + break + except ValueError: + require(time.monotonic() < deadline, "local_source_not_ready") + time.sleep(1) + except BaseException as error: + primary = error + raise + finally: + try: + bounded_command([*compose, "down", "--volumes", "--timeout", "15"], env=env, seconds=60) + for kind in ("container", "network", "volume"): + query = ["docker", kind, "ls", "--filter", "label=com.docker.compose.project=" + project, "-q"] + if kind == "container": + query.append("-a") + require(not bounded_command(query, env=env).strip(), "pair_fixture_cleanup") + except BaseException as error: + if primary is None: + raise + primary.add_note("owned image-pair cleanup failed: " + str(error)) + + def tag(self, image, source_sha): + namespace, digest = image.split("@", 1) + require(namespace in {IMAGE, SITE_IMAGE} and re.fullmatch(r"sha256:[0-9a-f]{64}", digest) + and re.fullmatch(r"[0-9a-f]{40}", source_sha), "image_identity") + reference = "sha-" + source_sha + existing = registry_manifest(namespace, reference) + if existing is not None: + require("sha256:" + hashlib.sha256(existing).hexdigest() == digest, "immutable_tag_conflict") + return + fresh_context(Path(__file__).resolve().parents[2], os.environ) + bounded_command(["docker", "buildx", "imagetools", "create", "--tag", namespace + ":" + reference, image], seconds=90) + observed = registry_manifest(namespace, reference) + require(observed is not None and "sha256:" + hashlib.sha256(observed).hexdigest() == digest, "immutable_tag_digest") + + def promote(self, image): + require(re.fullmatch(re.escape(IMAGE) + r"@sha256:[0-9a-f]{64}", image), "image_identity") + fresh_context(Path(__file__).resolve().parents[2], os.environ) + bounded_command(["docker", "buildx", "imagetools", "create", "--tag", IMAGE + ":stable", image], seconds=90) + raw = bounded_command(["docker", "buildx", "imagetools", "inspect", "--raw", IMAGE + ":stable"], seconds=30) + require("sha256:" + hashlib.sha256(raw).hexdigest() == image.split("@", 1)[1], "promoted_digest") + + +def authorized(context): + require(set(context) == {"event", "repository", "ref", "sha", "main_sha", "run_id", + "run_number", "attempt", "gates"}, "context_fields") + require(context["event"] in {"push", "workflow_dispatch"} + and context["repository"] == REPOSITORY + and context["ref"] == "refs/heads/main", "source_not_main") + require(isinstance(context["sha"], str) and re.fullmatch(r"[0-9a-f]{40}", context["sha"]) + and context["sha"] == context["main_sha"], "stale_or_invalid_sha") + for name, maximum in (("run_id", 2**53 - 1), ("run_number", 2**40), ("attempt", 999)): + require(type(context[name]) is int and 0 < context[name] <= maximum, "run_identity") + require(type(context["gates"]) is dict and GATES <= set(context["gates"]) <= GATES | {"architecture"} + and all(value == "success" for value in context["gates"].values()), "failed_gate") + + +def public_manifest(manifest): + result = validate_manifest(canonical_json(manifest)) + expected = "https://ai.v8std.ru/indexes/v1/" + result["archive"]["sha256"] + "/snapshot.tar.gz" + require(result["archive"]["path"] == expected, "public_archive_path") + return result + + +def expected_receipt(header): + manifest = header["manifest"] + return {"publication_id": header["publication_id"], "sequence": header["sequence"], + "action": header["action"], "trigger_sha": header["trigger_sha"], + "corpus_source_sha": manifest["source_sha"], "corpus_id": manifest["corpus_id"], + "archive_sha256": manifest["archive"]["sha256"]} + + +def committed(receipt, header): + require(type(receipt) is dict, "receipt_shape") + require(all(receipt.get(key) == value for key, value in expected_receipt(header).items()), + "receipt_identity") + require(receipt.get("state") == "COMMITTED" and receipt.get("error_code") is None + and receipt.get("cleanup_complete") is True, "receipt_not_committed") + + +class Publication: + def __init__(self, context, adapter): + authorized(context) + self.context, self.adapter = context, adapter + + def header(self, action, manifest): + context = self.context + # A rerun is a new attempt, not a mutation of a host's immutable receipt. + # Two ordered operations per attempt; gaps do not weaken host monotonicity. + sequence = (context["run_number"] * 1000 + context["attempt"]) * 2 + return validate_upload({ + "schema_version": 1, "publication_id": f"ci-{context['run_id']}-{context['attempt']}-{action}", + "sequence": sequence + (action == "reference"), "trigger_sha": context["sha"], + "manifest": public_manifest(manifest), "action": action, + "deadline": int(self.adapter.time()) + 300, + }) + + def acknowledge(self, header, archive=b""): + validate_upload(header) + wire = canonical_json(header) + b"\n" + require(len(wire) <= 65536, "header_size") + require(len(archive) == (header["manifest"]["archive"]["bytes"] + if header["action"] == "publish" else 0), "upload_size") + deadline = self.adapter.monotonic() + 300 + initial = self.adapter.command("publish-index", wire + archive, seconds=30) + require(initial.get("publication_id") == header["publication_id"], "receipt_identity") + query = canonical_json({"schema_version": 1, "kind": "publication", "id": header["publication_id"]}) + b"\n" + while self.adapter.monotonic() < deadline: + receipt = self.adapter.command("status", query, seconds=min(20, deadline - self.adapter.monotonic())) + require(type(receipt) is dict, "receipt_shape") + require(all(receipt.get(key) == value for key, value in expected_receipt(header).items()), + "receipt_identity") + require(receipt.get("state") in {"QUEUED", "RECEIVED", "VERIFIED", "COMMITTED"} + and receipt.get("error_code") is None, "publication_failed") + if receipt["state"] == "COMMITTED" and receipt.get("cleanup_complete") is True: + committed(receipt, header) + return receipt + self.adapter.sleep(min(2, deadline - self.adapter.monotonic())) + raise PublicationError("publication_timeout") + + def archive(self, manifest): + manifest = public_manifest(manifest) + payload = self.adapter.get(manifest["archive"]["path"], MAX_ARCHIVE_BYTES) + verify_archive(payload, manifest) + return payload + + def prepare(self, pages_path, manifest, archive, *, enabled): + require(type(enabled) is bool, "activation_type") + header = receipt = None + if enabled: + manifest = public_manifest(manifest) + verify_archive(archive, manifest) # Local verification precedes all host effects. + header = self.header("publish", manifest) + receipt = self.acknowledge(header, archive) + self.archive(manifest) # A successful receipt alone is not public byte proof. + else: + try: + payload = self.adapter.get(MANIFEST_URL, MAX_MANIFEST_BYTES) + except FileNotFoundError: + # Neither missing history nor HTTP404 proves initial absence. + raise PublicationError("published_manifest_missing") from None + manifest = public_manifest(validate_manifest(payload)) + self.archive(manifest) + pages_path = Path(pages_path) + # Caller passes its disposable Pages build, never the public site itself. + pages_path.parent.mkdir(parents=True, exist_ok=True) + _atomic_file(pages_path, canonical_json(manifest) + b"\n") + return {"manifest": manifest, "header": header, "receipt": receipt} + + def finish(self, prepared, *, image=None, runtime_sha=None, promote=False): + require(type(promote) is bool, "activation_type") + manifest, header = prepared["manifest"], prepared["header"] + require(manifest is not None, "default_source_absent") + if header is not None: + validate_upload(header) + require(header["action"] == "publish" and header["trigger_sha"] == self.context["sha"] + and header["manifest"] == manifest + and header["publication_id"] == self.header("publish", manifest)["publication_id"] + and header["sequence"] == self.header("publish", manifest)["sequence"], "prepared_identity") + committed(prepared["receipt"], header) + observed = public_manifest(validate_manifest(self.adapter.get(MANIFEST_URL, MAX_MANIFEST_BYTES))) + require(observed == manifest, "pages_manifest_mismatch") + self.archive(manifest) + if header is not None: + self.acknowledge(self.header("reference", manifest)) + if image is not None: + require(isinstance(image, str) and re.fullmatch(re.escape(IMAGE) + r"@sha256:[0-9a-f]{64}", image), + "image_identity") + require(isinstance(runtime_sha, str) and re.fullmatch(r"[0-9a-f]{40}", runtime_sha), "runtime_sha") + self.adapter.smoke(image, runtime_sha, manifest) + require(not promote or image is not None, "image_required") + if promote: + self.adapter.promote(image) + return manifest + + +def api(path): + require(path.startswith(f"repos/{REPOSITORY}/"), "api_scope") + return strict_json(bounded_command(["gh", "api", path], seconds=30)) + + +def download_state(path): + require(re.fullmatch(r"repos/zeegin/v8std/actions/artifacts/[0-9]+/zip", path), "api_scope") + return bounded_command(["gh", "api", path], seconds=30, limit=256 * 1024) + + +def activated(environ, name): + value = environ.get(name, "") + require(value in {"", "false", "true"}, "activation_value") + return value == "true" + + +def ci_context(environ, *, main_sha=None): + try: + context = {"event": environ["GITHUB_EVENT_NAME"], "repository": environ["GITHUB_REPOSITORY"], + "ref": environ["GITHUB_REF"], "sha": environ["GITHUB_SHA"], "main_sha": main_sha, + "run_id": int(environ["GITHUB_RUN_ID"]), "run_number": int(environ["GITHUB_RUN_NUMBER"]), + "attempt": int(environ["GITHUB_RUN_ATTEMPT"]), + "gates": strict_json(environ.get("MCP_GATES", "{}").encode())} + except (KeyError, ValueError): + raise PublicationError("ci_context") from None + require(re.fullmatch(r"[0-9a-f]{40}", context["sha"]), "source_sha") + return context + + +def save(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + require(not path.is_symlink(), "output_symlink") + _atomic_file(path, canonical_json(value) + b"\n") + + +def load(path): + return strict_json(read_file(Path(path), 256 * 1024)) + + +def plan_sources(root, environ, *, gates=False): + context = ci_context(environ) + require(git(root, "rev-parse", "HEAD").decode().strip() == context["sha"], "checkout_sha") + identities = input_identities(root, context["sha"]) + published = {"runtime": None, "corpus": None} + if (context["event"] in {"push", "workflow_dispatch"} and context["repository"] == REPOSITORY + and context["ref"] == "refs/heads/main"): + context["main_sha"] = api(f"repos/{REPOSITORY}/branches/main")["commit"]["sha"] + require(context["sha"] == context["main_sha"], "stale_or_invalid_sha") + require(environ.get("GITHUB_WORKFLOW_REF") == "zeegin/v8std/.github/workflows/ci.yml@refs/heads/main", + "workflow_identity") + sequence = context["run_number"] * 1000 + context["attempt"] + for kind in published: + try: + published[kind] = last_published(root, context["sha"], sequence, kind, api, download_state) + except PublicationError as error: + if str(error) != "published_state_expired": + raise + # Recover only through existing externally verified identities, + # never treat expired JSON as trustworthy or salt a new image. + if published["corpus"] is None: + adapter = CITransport() + try: + manifest = public_manifest(validate_manifest(adapter.get(MANIFEST_URL, MAX_MANIFEST_BYTES))) + except FileNotFoundError: + # Planning may build a candidate, but None remains UNKNOWN and + # cannot authorize manifest-less Pages publication. + manifest = None + if manifest is not None: + git(root, "merge-base", "--is-ancestor", manifest["source_sha"], context["sha"]) + verify_archive(adapter.get(manifest["archive"]["path"], MAX_ARCHIVE_BYTES), manifest) + published["corpus"] = {"source_sha": manifest["source_sha"], "manifest": manifest, + "input_id": input_identities(root, manifest["source_sha"])["corpus"]} + image_enabled = activated(environ, "MCP_IMAGE_PUBLICATION_ENABLED") + runtime_enabled = activated(environ, "MCP_RUNTIME_DEPLOY_ENABLED") + if published["runtime"] is None and (image_enabled or runtime_enabled): + published["runtime"] = equivalent_runtime(root, context["sha"], identities["runtime"]) + if gates: + authorized(context) + return {"trigger_sha": context["sha"], "identities": identities, "published": published, + **choose_sources(context["sha"], identities, published)} + + +def verify_build(directory): + plan = load(directory / "plan.json") + manifest = public_manifest(load(directory / "snapshot/manifest.json")) + require(manifest["source_sha"] == plan["corpus_sha"], "built_corpus_source") + name = manifest["archive"]["sha256"] + "/snapshot.tar.gz" + archive = read_file(directory / "snapshot" / name, MAX_ARCHIVE_BYTES) + verify_archive(archive, manifest) + local = load(directory / "local-site/ai/mcp/v1/manifest.json") + require(local["archive"]["path"] == name, "local_archive_path") + local["archive"]["path"] = manifest["archive"]["path"] + require(local == manifest and read_file(directory / "local-site/ai/mcp/v1" / name, MAX_ARCHIVE_BYTES) == archive, + "public_local_corpus_mismatch") + prior = plan["published"]["corpus"] + if not plan["corpus_changed"]: + require(prior is not None and prior["manifest"] == manifest, "unchanged_corpus_rebuilt_differently") + return manifest, archive + + +def fresh_context(root, environ, environment="github-pages"): + branch = api(f"repos/{REPOSITORY}/branches/main") + context = ci_context(environ, main_sha=branch["commit"]["sha"]) + authorized(context) + require(git(root, "rev-parse", "HEAD").decode().strip() == context["sha"], "checkout_sha") + require(environ.get("GITHUB_WORKFLOW_REF") == "zeegin/v8std/.github/workflows/ci.yml@refs/heads/main", + "workflow_identity") + switches = [activated(environ, name) for name in ( + "MCP_IMAGE_PUBLICATION_ENABLED", "MCP_CORPUS_PUBLICATION_ENABLED", "MCP_RUNTIME_DEPLOY_ENABLED")] + if any(switches): + check_external_protection(branch, api(f"repos/{REPOSITORY}/environments/{environment}"), + api(f"repos/{REPOSITORY}/environments/{environment}/deployment-branch-policies")) + return context + + +@contextmanager +def transport(environ): + """Only ephemeral owner-private key files; never edit ~/.ssh or Docker config.""" + with tempfile.TemporaryDirectory(prefix="v8std-release-ssh-") as directory: + paths = [] + for name, setting in (("key", "MCP_RELEASE_SSH_KEY"), ("known-hosts", "MCP_RELEASE_KNOWN_HOSTS")): + value = environ.get(setting, "") + require(isinstance(value, str) and len(value.encode()) <= 65536 and "\0" not in value, "ssh_secret_size") + path = Path(directory) / name + with path.open("xb") as output: + os.fchmod(output.fileno(), 0o600) + output.write(value.encode()) + paths.append(str(path) if value else None) + yield CITransport(release_host=environ.get("MCP_RELEASE_HOST"), key=paths[0], known_hosts=paths[1]) + + +def milestone(context, plan, kind, *, image_digest=None, manifest=None): + return {"schema_version": 1, "kind": kind, + "sequence": context["run_number"] * 1000 + context["attempt"], + "trigger_sha": context["sha"], "source_sha": plan[kind + "_sha"], + "input_id": plan["identities"][kind], + **({"image_digest": image_digest} if kind == "runtime" else {"manifest": manifest})} + + +def registry_request(image, suffix): + """Read-only GHCR token/manifest exchange; credentials only on stdin. + + A 404 is absence; authentication failure or transport failure never grants + permission to overwrite a supposedly missing immutable tag. + """ + require(image in {IMAGE, "ghcr.io/zeegin/v8std-site"}, "registry_namespace") + repository = image.removeprefix("ghcr.io/") + def request(url, authorization=b""): + output = bounded_command(["curl", "-q", "--silent", "--show-error", "--proto", "=https", + "--max-time", "30", "--max-redirs", "0", "--connect-timeout", "10", "--max-filesize", "2097152", + "--header", "@-", "--header", "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json", + "--write-out", "\n%{http_code}", url], payload=authorization, seconds=32, limit=2 * 1024 * 1024 + 4) + body, status = output.rsplit(b"\n", 1) + require(re.fullmatch(rb"[0-9]{3}", status), "registry_status") + return int(status), body + authorization = b"" + if os.environ.get("GH_TOKEN"): + credentials = (os.environ.get("GITHUB_ACTOR", "x-access-token") + ":" + os.environ["GH_TOKEN"]).encode() + authorization = b"Authorization: Basic " + base64.b64encode(credentials) + b"\n" + status, raw = request("https://ghcr.io/token?service=ghcr.io&scope=repository:" + repository + ":pull", authorization) + require(status == 200, "registry_authorization") + token = strict_json(raw).get("token") + require(isinstance(token, str) and 0 < len(token) <= 16384 + and all(33 <= ord(char) <= 126 for char in token), "registry_token") + return request("https://ghcr.io/v2/" + repository + "/" + suffix, + b"Authorization: Bearer " + token.encode() + b"\n") + + +def registry_manifest(image, reference): + require(re.fullmatch(r"sha-[0-9a-f]{40}|sha256:[0-9a-f]{64}", reference), "registry_reference") + status, raw = registry_request(image, "manifests/" + reference) + if status == 404: + return None + require(status == 200, "registry_manifest_unavailable") + value = strict_json(raw) + require(value.get("schemaVersion") == 2 and value.get("mediaType") in { + "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json"}, "image_index") + if reference.startswith("sha256:"): + require("sha256:" + hashlib.sha256(raw).hexdigest() == reference, "registry_digest") + return raw + + +def registry_tags(): + status, raw = registry_request(IMAGE, "tags/list?n=1000") + if status == 404: + return [] + require(status == 200, "registry_tags_unavailable") + value = strict_json(raw) + require(value.get("name") == IMAGE.removeprefix("ghcr.io/") and isinstance(value.get("tags"), list) + and len(value["tags"]) < 1000 and all(isinstance(tag, str) for tag in value["tags"]), "registry_tags_window") + return value["tags"] + + +def equivalent_runtime(root, main_sha, identity): + """Recovery from existing immutable tags + main ancestry + attestations. + + No new registry metadata/schema. Only equivalent inputs can be reused without + a CI milestone; a different arbitrary historical image is not 'last success'. + The bounded window fails closed instead of silently ignoring older tags. + """ + candidates = {tag[4:] for tag in registry_tags() if re.fullmatch(r"sha-[0-9a-f]{40}", tag)} + deadline = time.monotonic() + 120 + checked = 0 + for source in git(root, "rev-list", "--topo-order", main_sha).decode().splitlines(): + if source not in candidates: + continue + checked += 1 + require(checked <= 64 and time.monotonic() < deadline, "identity_recovery_window") + if input_identities(root, source)["runtime"] != identity: + continue + raw = registry_manifest(IMAGE, "sha-" + source) + require(raw is not None, "immutable_image_disappeared") + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + bounded_command(attestation_command("oci://" + IMAGE + "@" + digest, source), seconds=90) + return {"source_sha": source, "input_id": identity, "image_digest": digest} + return None + + +def select_image(plan): + source = plan["runtime_sha"] + require(isinstance(source, str) and re.fullmatch(r"[0-9a-f]{40}", source), "runtime_sha") + if not plan["runtime_changed"]: + previous = plan["published"]["runtime"] + require(previous is not None and previous["source_sha"] == source, "published_runtime_required") + return {"build": False, "source_sha": source, "image_digest": previous["image_digest"]} + raw = registry_manifest(IMAGE, "sha-" + source) + if raw is None: + return {"build": True, "source_sha": source, "image_digest": None} + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + bounded_command(attestation_command("oci://" + IMAGE + "@" + digest, source), seconds=90) + return {"build": False, "source_sha": source, "image_digest": digest} + + +def select_site(source): + require(isinstance(source, str) and re.fullmatch(r"[0-9a-f]{40}", source), "site_sha") + raw = registry_manifest(SITE_IMAGE, "sha-" + source) + digest = "sha256:" + hashlib.sha256(raw).hexdigest() if raw is not None else None + if digest: + bounded_command(attestation_command("oci://" + SITE_IMAGE + "@" + digest, source), seconds=90) + return {"build": raw is None, "source_sha": source, "image_digest": digest} + + +def record_images(context, adapter, runtime, site, manifest, *, runtime_digest, site_digest): + authorized(context) + references = [] + for selected, built, namespace in ((runtime, runtime_digest, IMAGE), (site, site_digest, SITE_IMAGE)): + digest = built if selected["build"] else selected["image_digest"] + require(isinstance(digest, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", digest), "image_digest") + require(not selected["build"] or selected["source_sha"] == context["sha"], "rebuilt_source_identity") + image = namespace + "@" + digest + bounded_command(attestation_command("oci://" + image, selected["source_sha"]), seconds=90) + references.append(image) + adapter.local_smoke(references[0], runtime["source_sha"], references[1], site["source_sha"], manifest) + # Initial push is by digest only. An interrupted/unverified candidate never + # occupies an immutable source tag; reruns do not overwrite existing tags. + for image, selected in zip(references, (runtime, site)): + adapter.tag(image, selected["source_sha"]) + return {"source_sha": runtime["source_sha"], "image_digest": references[0].split("@", 1)[1]} + + +def verify_running_runtime(source_sha): + """A COMMITTED journal is not health; exercise the actual TLS/MCP endpoint. + + A valid stale corpus is allowed by the runtime contract. Bracket real tool + calls with its current generation, rather than demanding an immediate refresh. + """ + deadline = time.monotonic() + 30 + url = "https://ai.v8std.ru" + health = strict_json(release_http(url + "/healthz", deadline)) + record = {"runtime_source_sha": source_sha, "corpus_id": health.get("corpus_id"), + "archive_sha256": health.get("archive_sha256"), "hold_token": health.get("hold_token")} + require(all(isinstance(record[key], str) and re.fullmatch(r"[0-9a-f]{64}", record[key]) + for key in ("corpus_id", "archive_sha256")), "live_corpus_identity") + return runtime_smoke(url, record, deadline) + + +def deploy_runtime(context, adapter, accepted, *, enabled, configuration_digest, platform): + authorized(context) + require(type(enabled) is bool, "activation_type") + if not enabled: + return {"state": "DISABLED"} + runtime = accepted.get("runtime") + require(isinstance(runtime, dict) and re.fullmatch(r"sha256:[0-9a-f]{64}", runtime.get("image_digest", "")) + and re.fullmatch(r"[0-9a-f]{40}", runtime.get("source_sha", "")), "published_runtime_required") + manifest = public_manifest(accepted["manifest"]) + require(isinstance(configuration_digest, str) and re.fullmatch(r"[0-9a-f]{64}", configuration_digest) + and platform in {"linux/amd64", "linux/arm64"}, "runtime_configuration") + previous = adapter.command("status", b"") + require(previous.get("state") in {"COMMITTED", "FAILED", "ROLLED_BACK"} + and previous.get("cleanup_complete") is True, "predecessor_or_recovery_required") + if (previous["state"] == "COMMITTED" and previous.get("error_code") is None + and previous.get("image_digest") == runtime["image_digest"] + and previous.get("configuration_digest") == configuration_digest): + require(previous.get("runtime_source_sha") == runtime["source_sha"], "active_identity") + verify_running_runtime(runtime["source_sha"]) + return {"state": "UNCHANGED", "image_digest": runtime["image_digest"]} + raw = registry_manifest(IMAGE, runtime["image_digest"]) + require(raw is not None, "published_runtime_required") + os_name, architecture = platform.split("/") + descriptors = [item for item in strict_json(raw).get("manifests", []) + if isinstance(item, dict) and isinstance(item.get("platform"), dict) + and item["platform"].get("os") == os_name and item["platform"].get("architecture") == architecture + and item["platform"].get("variant", "") in ({"", "v8"} if architecture == "arm64" else {""})] + require(len(descriptors) == 1, "platform_descriptor") + envelope = {"schema_version": 1, "release_id": f"ci-{context['run_id']}-{context['attempt']}", + "sequence": context["run_number"] * 1000 + context["attempt"], "trigger_sha": context["sha"], + "runtime_source_sha": runtime["source_sha"], "image": IMAGE, "image_digest": runtime["image_digest"], + "platform_digest": descriptors[0]["digest"], "configuration_digest": configuration_digest, + "corpus_id": manifest["corpus_id"], "archive_sha256": manifest["archive"]["sha256"], + "deadline": int(adapter.time()) + 300} + validate_envelope(canonical_json(envelope), now=adapter.time()) + wire = canonical_json(envelope) + b"\n" + deadline = adapter.monotonic() + 300 + require(adapter.command("validate-envelope", wire) == envelope, "validated_envelope_identity") + initial = adapter.command("deploy", wire) + require(isinstance(initial, dict) and initial.get("release_id") == envelope["release_id"], "release_queue_identity") + query = canonical_json({"schema_version": 1, "kind": "release", "id": envelope["release_id"]}) + b"\n" + while adapter.monotonic() < deadline: + result = adapter.command("status", query, seconds=min(20, deadline - adapter.monotonic())) + require(isinstance(result, dict) and all(result.get(key) == value for key, value in envelope.items()), "release_receipt_identity") + require(result.get("state") in {"QUEUED", "RECEIVED", "VERIFIED", "PREPARED", "READY", "SWITCHED", "COMMITTED"} + and result.get("error_code") is None, "release_failed") + if result.get("state") == "COMMITTED" and result.get("cleanup_complete") is True: + require(result.get("error_code") is None, "release_failed") + verify_running_runtime(runtime["source_sha"]) + return result + adapter.sleep(min(2, deadline - adapter.monotonic())) + raise PublicationError("release_timeout") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["plan", "value", "guard", "verify-build", "image-plan", "record-image", + "prepare-pages", "finish", "deploy"]) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--directory", type=Path, default=Path(".ci")) + parser.add_argument("--field", choices=["runtime_sha", "corpus_sha", "runtime_changed", "corpus_changed"]) + parser.add_argument("--environment", choices=["github-pages", "mcp-production"]) + args = parser.parse_args(argv) + root, directory = args.root.resolve(), args.directory.resolve() + try: + if args.command == "plan": + save(directory / "plan.json", plan_sources(root, os.environ)) + elif args.command == "value": + require(args.field is not None, "field_required") + value = load(directory / "plan.json")[args.field] + print(str(value).lower() if isinstance(value, bool) else value) + elif args.command == "guard": + require(args.environment is not None, "environment_required") + plan = plan_sources(root, os.environ, gates=True) + switches = [activated(os.environ, name) for name in ( + "MCP_IMAGE_PUBLICATION_ENABLED", "MCP_CORPUS_PUBLICATION_ENABLED", "MCP_RUNTIME_DEPLOY_ENABLED")] + enabled = any(switches) + if enabled: + branch = api(f"repos/{REPOSITORY}/branches/main") + environment = api(f"repos/{REPOSITORY}/environments/{args.environment}") + policies = api(f"repos/{REPOSITORY}/environments/{args.environment}/deployment-branch-policies") + check_external_protection(branch, environment, policies) + if (directory / "plan.json").exists(): + original = load(directory / "plan.json") + require(all(plan[key] == original[key] for key in ("runtime_sha", "corpus_sha", "identities", "trigger_sha")), + "source_plan_changed_since_build") + save(directory / "plan.json", plan) + elif args.command == "verify-build": + manifest, _ = verify_build(directory) + print("verified public/local corpus " + manifest["corpus_id"]) + else: + context = fresh_context(root, os.environ, "mcp-production" if args.command == "deploy" else "github-pages") + plan = load(directory / "plan.json") + require(plan["trigger_sha"] == context["sha"], "plan_trigger") + with transport(os.environ) as adapter: + publication = Publication(context, adapter) + if args.command == "image-plan": + require(activated(os.environ, "MCP_IMAGE_PUBLICATION_ENABLED"), "image_publication_disabled") + images = {"runtime": select_image(plan), "site": select_site(context["sha"])} + save(directory / "images.json", images) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write("build=" + str(images["runtime"]["build"]).lower() + "\n") + output.write("site_build=" + str(images["site"]["build"]).lower() + "\n") + elif args.command == "record-image": + require(activated(os.environ, "MCP_IMAGE_PUBLICATION_ENABLED"), "image_publication_disabled") + manifest, _ = verify_build(directory) + images = load(directory / "images.json") + require(images["runtime"]["source_sha"] == plan["runtime_sha"] + and images["site"]["source_sha"] == context["sha"], "image_plan_source") + runtime = record_images(context, adapter, images["runtime"], images["site"], manifest, + runtime_digest=os.environ.get("MCP_BUILT_DIGEST"), site_digest=os.environ.get("MCP_SITE_BUILT_DIGEST")) + save(directory / "runtime-state/state.json", milestone(context, plan, "runtime", + image_digest=runtime["image_digest"])) + elif args.command == "prepare-pages": + manifest, archive = verify_build(directory) + enabled = activated(os.environ, "MCP_CORPUS_PUBLICATION_ENABLED") + prepared = publication.prepare(root / "site/ai/mcp/v1/manifest.json", manifest, archive, + enabled=enabled) + save(directory / "prepared.json", prepared) + if enabled: + save(directory / "corpus-state/state.json", milestone(context, plan, "corpus", manifest=manifest)) + elif args.command == "finish": + runtime = (load(directory / "runtime-state/state.json") if (directory / "runtime-state/state.json").exists() + else plan["published"]["runtime"]) + image_enabled = activated(os.environ, "MCP_IMAGE_PUBLICATION_ENABLED") + require(not image_enabled or runtime is not None, "published_runtime_required") + manifest = publication.finish(load(directory / "prepared.json"), + image=IMAGE + "@" + runtime["image_digest"] if image_enabled else None, + runtime_sha=runtime["source_sha"] if runtime else None, promote=image_enabled) + save(directory / "accepted.json", {"trigger_sha": context["sha"], "runtime": runtime, "manifest": manifest}) + elif args.command == "deploy": + accepted = load(directory / "accepted.json") + require(accepted["trigger_sha"] == context["sha"], "accepted_trigger") + result = deploy_runtime(context, adapter, accepted, + enabled=activated(os.environ, "MCP_RUNTIME_DEPLOY_ENABLED"), + configuration_digest=os.environ.get("MCP_CONFIGURATION_DIGEST"), platform=os.environ.get("MCP_PLATFORM")) + print("runtime " + result["state"]) + except (ValueError, OSError, subprocess.SubprocessError) as error: + code = str(error) if isinstance(error, PublicationError) else getattr(error, "code", type(error).__name__) + parser.exit(1, code + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/delivery/index/__init__.py b/delivery/index/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/delivery/index/generate_mcp_snapshot.py b/delivery/index/generate_mcp_snapshot.py new file mode 100644 index 0000000..42e8dcb --- /dev/null +++ b/delivery/index/generate_mcp_snapshot.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Build and atomically publish an immutable MCP corpus from existing docs outputs.""" + +from __future__ import annotations + +import argparse +import gzip +import io +import json +import os +from pathlib import Path +import tempfile + +from runtime.v8std_mcp_presentation import PresentationError, validate_links + +from runtime.v8std_mcp_snapshot_format import ( + DEFAULT_SITE_URL, JSONL_MEMBERS, MAX_ARCHIVE_BYTES, MAX_MANIFEST_BYTES, + MAX_UNPACKED_BYTES, MEMBER_LIMITS, MEMBERS, PUBLIC_DELIVERY_URL, + SnapshotError, VECTOR_DIM, VECTOR_MODEL, canonical_json, jsonl_rows, + normalize_site_url, portable_page, sha256, tar_header, validate_manifest, verify_archive, +) + + +def _read(path: Path, maximum: int) -> bytes: + try: + with path.open("rb") as stream: + payload = stream.read(maximum + 1) + except OSError: + raise SnapshotError("source_io") from None + if len(payload) > maximum: + raise SnapshotError("member_size") + return payload + + +def build_snapshot(docs_dir: Path, source_sha: str, canonical_site_url: str) -> tuple[bytes, dict]: + site_url = normalize_site_url(canonical_site_url) + docs_dir = Path(docs_dir) + files = { + name: _read(docs_dir / ("ai" if name in JSONL_MEMBERS else "") / name, MEMBER_LIMITS[name]) + for name in MEMBERS[1:] + } + pages = bytearray() + page_count = 0 + for page in jsonl_rows(files["pages.jsonl"]): + row = portable_page(page, site_url) + pages.extend(json.dumps(row, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + b"\n") + if len(pages) > MEMBER_LIMITS["pages.jsonl"]: + raise SnapshotError("member_size") + page_count += 1 + files["pages.jsonl"] = bytes(pages) + rows = list(jsonl_rows(files["pages.jsonl"])) + page_paths = {row["id"]: row for row in rows} + for row in rows: + validate_links(row.get("body_markdown", ""), canonical_site_url=site_url, + page_paths=page_paths, context=site_url + row["site_path"]) + for name in ("llms.txt", "llms-full.txt"): + validate_links(files[name].decode("utf-8"), canonical_site_url=site_url, + page_paths=page_paths, generated_fields=True) + vector_count = sum(1 for _ in jsonl_rows(files["search-vectors.jsonl"])) + counts = dict(zip(JSONL_MEMBERS, (page_count, vector_count))) + descriptor = { + "schema_version": 1, "source_sha": source_sha, "canonical_site_url": site_url, + "vector_model": VECTOR_MODEL, "vector_dim": VECTOR_DIM, + "files": { + name: {"sha256": sha256(payload), "bytes": len(payload), + **({"rows": counts[name]} if name in counts else {})} + for name, payload in files.items() + }, + } + metadata = {**descriptor, "corpus_id": sha256(canonical_json(descriptor))} + files = {"metadata.json": canonical_json(metadata), **files} + if len(files["metadata.json"]) > MEMBER_LIMITS["metadata.json"]: + raise SnapshotError("member_size") + raw = bytearray() + for name in MEMBERS: + payload = files[name] + raw.extend(tar_header(name, len(payload))) + raw.extend(payload) + raw.extend(b"\0" * (-len(payload) % 512)) + raw.extend(b"\0" * 1024) + raw.extend(b"\0" * (-len(raw) % 10240)) + if len(raw) > MAX_UNPACKED_BYTES: + raise SnapshotError("archive_unpacked_size") + output = io.BytesIO() + with gzip.GzipFile(fileobj=output, mode="wb", filename="", mtime=0, compresslevel=9) as stream: + stream.write(raw) + archive = output.getvalue() + digest = sha256(archive) + manifest = { + "schema_version": 1, "source_sha": source_sha, "corpus_id": metadata["corpus_id"], + "vector_model": VECTOR_MODEL, "vector_dim": VECTOR_DIM, + "archive": {"path": digest + "/snapshot.tar.gz", "sha256": digest, + "bytes": len(archive), "unpacked_bytes": sum(map(len, files.values()))}, + } + validate_manifest(canonical_json(manifest)) + verify_archive(archive, manifest) + return archive, manifest + + +def _fsync_directory(path: Path) -> None: + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _atomic_file(path: Path, payload: bytes, *, immutable: bool = False) -> None: + """Install durable complete bytes. link() is an atomic no-clobber publish.""" + temporary_path = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".snapshot-", delete=False) as stream: + temporary_path = Path(stream.name) + stream.write(payload) + stream.flush() + os.fchmod(stream.fileno(), 0o644) + os.fsync(stream.fileno()) + if immutable: + os.link(temporary_path, path) + else: + os.replace(temporary_path, path) + _fsync_directory(path.parent) + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def publish_snapshot(docs_dir: Path, output_dir: Path, source_sha: str, + canonical_site_url: str, *, public_delivery: bool = False) -> Path: + site_url = normalize_site_url(canonical_site_url) + if public_delivery and site_url != DEFAULT_SITE_URL: + raise SnapshotError("archive_path") + archive, manifest = build_snapshot(docs_dir, source_sha, site_url) + output_dir = Path(output_dir) + archive_dir = output_dir / manifest["archive"]["sha256"] + archive_path = archive_dir / "snapshot.tar.gz" + try: + output_dir.mkdir(parents=True, exist_ok=True) + if archive_dir.is_symlink(): + raise SnapshotError("immutable_conflict") + archive_dir.mkdir(exist_ok=True) + _fsync_directory(output_dir.parent) + try: + _atomic_file(archive_path, archive, immutable=True) + except FileExistsError: + if archive_path.is_symlink() or not archive_path.is_file(): + raise SnapshotError("immutable_conflict") from None + existing = _read(archive_path, MAX_ARCHIVE_BYTES) + if existing != archive: + raise SnapshotError("immutable_conflict") from None + verify_archive(existing, manifest) + # Persist the directory entry before publishing a reference to it. + _fsync_directory(archive_dir) + _fsync_directory(output_dir) + if public_delivery: + manifest["archive"]["path"] = PUBLIC_DELIVERY_URL + manifest["archive"]["path"] + encoded_manifest = canonical_json(manifest) + validate_manifest(encoded_manifest) + if len(encoded_manifest) > MAX_MANIFEST_BYTES: + raise SnapshotError("manifest_size") + manifest_path = output_dir / "manifest.json" + _atomic_file(manifest_path, encoded_manifest + b"\n") + return manifest_path + except OSError: + raise SnapshotError("publish_io") from None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--docs", type=Path, default=Path("docs")) + parser.add_argument("--output", type=Path, required=True, + help="Manifest directory; immutable hash directories are created below it.") + parser.add_argument("--source-sha", required=True) + parser.add_argument("--site-url", default=None) + parser.add_argument("--public-delivery", action="store_true") + args = parser.parse_args() + site_url = args.site_url if args.site_url is not None else os.environ.get("V8STD_MCP_SITE_URL", DEFAULT_SITE_URL) + try: + path = publish_snapshot(args.docs, args.output, args.source_sha, site_url, + public_delivery=args.public_delivery) + except (SnapshotError, PresentationError) as error: + parser.exit(1, f"{error.code}\n") + print(f"wrote {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/delivery/local/compose.yaml b/delivery/local/compose.yaml new file mode 100644 index 0000000..78a38d8 --- /dev/null +++ b/delivery/local/compose.yaml @@ -0,0 +1,57 @@ +# Release distribution: ready site and MCP images. +# Both overrides must identify an actually published release or a local test image. +services: + site: + image: ${V8STD_SITE_IMAGE:?Set the verified ghcr.io/zeegin/v8std-site tag or digest} + user: "10001:10001" + read_only: true + init: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: ["/tmp:rw,noexec,nosuid,size=32m,uid=10001,gid=10001"] + mem_limit: 128m + cpus: 0.5 + # The image listens on 8000 by default; changing the common port needs + # only a temporary nginx config, leaving the image/root read-only. + entrypoint: [/bin/sh, -ec] + command: + - 'sed "s/listen 8000;/listen ${V8STD_SITE_PORT:-18765};/" /etc/nginx/nginx.conf > /tmp/site.conf; exec nginx -c /tmp/site.conf -g "daemon off;"' + ports: + - "127.0.0.1:${V8STD_SITE_PORT:-18765}:${V8STD_SITE_PORT:-18765}" + - "127.0.0.1:${V8STD_MCP_PORT:-18766}:8001" + networks: + corpus: + aliases: [v8std.localhost] + publish: {} + mcp: + image: ${V8STD_MCP_IMAGE:?Set the verified ghcr.io/zeegin/v8std-mcp tag or digest} + profiles: [mcp] + user: "10001:10001" + read_only: true + init: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: ["/tmp:rw,noexec,nosuid,size=64m,uid=10001,gid=10001"] + mem_limit: 1536m + cpus: 2 + pids_limit: 128 + volumes: [mcp-cache:/var/lib/v8std-mcp] + environment: + V8STD_MCP_SITE_URL: "${V8STD_MCP_SITE_URL:-http://v8std.localhost:${V8STD_SITE_PORT:-18765}${V8STD_SITE_PREFIX:-/}}" + V8STD_MCP_MAX_SNIPPET_CHARS: "${V8STD_MCP_MAX_SNIPPET_CHARS:-4000}" + command: [--transport, streamable-http, --host, 0.0.0.0, --port, "8000"] + # Host HTTP goes through the site's loopback proxy; MCP has no egress route. + networks: [corpus] + depends_on: [site] + healthcheck: + test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"] + interval: 10s + timeout: 3s + start_period: 90s + retries: 3 +volumes: + mcp-cache: +networks: + corpus: + internal: true + publish: {} diff --git a/delivery/mcp/Dockerfile b/delivery/mcp/Dockerfile new file mode 100644 index 0000000..e0aaf50 --- /dev/null +++ b/delivery/mcp/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea +ARG SOURCE_SHA +LABEL org.opencontainers.image.title="v8std MCP" \ + org.opencontainers.image.source="https://github.com/zeegin/v8std" \ + org.opencontainers.image.revision="${SOURCE_SHA}" \ + org.opencontainers.image.licenses="CC0-1.0" +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ + V8STD_MCP_CACHE_DIR=/var/lib/v8std-mcp V8STD_MCP_RUNTIME_SHA=${SOURCE_SHA} +WORKDIR /opt/v8std +COPY runtime/requirements-mcp.lock ./runtime/ +RUN python -c 'import os,re; assert re.fullmatch("[0-9a-f]{40}",os.environ["V8STD_MCP_RUNTIME_SHA"])' \ + && python -m pip install --no-cache-dir --require-hashes -r runtime/requirements-mcp.lock \ + && python -m pip check \ + && mkdir -p /var/lib/v8std-mcp \ + && chown 10001:10001 /var/lib/v8std-mcp +COPY runtime/__init__.py runtime/v8std_mcp_server.py runtime/v8std_mcp_runtime.py runtime/v8std_mcp_index.py \ + runtime/v8std_mcp_snapshots.py runtime/v8std_mcp_snapshot_format.py runtime/v8std_mcp_hold.py \ + runtime/v8std_mcp_presentation.py ./runtime/ +COPY scripts/v8std_mcp_chunks.py scripts/v8std_retrieval_rules.py scripts/v8std_search_features.py ./scripts/ +COPY retrieval-rules.yml LICENSE ./ +COPY LICENSES ./LICENSES/ +USER 10001:10001 +ENTRYPOINT ["python", "-m", "runtime.v8std_mcp_server"] +EXPOSE 8000 +CMD ["--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000"] diff --git a/delivery/site/Dockerfile b/delivery/site/Dockerfile new file mode 100644 index 0000000..d026c4b --- /dev/null +++ b/delivery/site/Dockerfile @@ -0,0 +1,36 @@ +# The named build context is a prepared local-profile output, never the public site. +FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS local-builder +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /build +COPY requirements-build.lock ./ +RUN python -m pip install --no-cache-dir --require-hashes -r requirements-build.lock \ + && python -m pip check +COPY docs ./docs/ +COPY overrides ./overrides/ +COPY delivery/__init__.py ./delivery/ +COPY delivery/site/__init__.py delivery/site/build_local_site.py ./delivery/site/ +COPY delivery/index/__init__.py delivery/index/generate_mcp_snapshot.py ./delivery/index/ +COPY runtime/__init__.py runtime/v8std_mcp_snapshot_format.py runtime/v8std_mcp_presentation.py ./runtime/ +COPY scripts/v8std_mcp_chunks.py scripts/generate_ai_artifacts.py scripts/generate_social_cards.py \ + scripts/v8std_retrieval_rules.py scripts/v8std_search_features.py scripts/v8std_markdown.py \ + scripts/atomic_files.py scripts/publish_license_texts.py ./scripts/ +COPY LICENSES ./LICENSES/ +COPY zensical.toml retrieval-rules.yml LICENSE ./ +RUN chmod -R a+rX /build +USER 10001:10001 +ENTRYPOINT ["python", "-m", "delivery.site.build_local_site"] + +FROM nginx:stable-alpine@sha256:dc5069ad14f19660b141b21236140b91656bf89bbc3e2417c70ae650cd66104c +ARG SOURCE_SHA +ARG SITE_PREFIX= +LABEL org.opencontainers.image.title="v8std local site" \ + org.opencontainers.image.source="https://github.com/zeegin/v8std" \ + org.opencontainers.image.revision="${SOURCE_SHA}" \ + org.opencontainers.image.licenses="CC0-1.0" +COPY delivery/site/site.conf /etc/nginx/nginx.conf +COPY --from=local-site --chown=10001:10001 / /srv/site/${SITE_PREFIX}/ +COPY LICENSE /usr/share/licenses/v8std/LICENSE +USER 10001:10001 +EXPOSE 8000 +ENTRYPOINT ["nginx"] +CMD ["-g", "daemon off;"] diff --git a/delivery/site/__init__.py b/delivery/site/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/delivery/site/build_local_site.py b/delivery/site/build_local_site.py new file mode 100644 index 0000000..2d3434e --- /dev/null +++ b/delivery/site/build_local_site.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Build local HTML from prepared canonical inputs in an isolated staging tree. + +Does not call zensical_docs.sh: that wrapper regenerates the canonical corpus. +The release builder must prepare docs/ai and llms files before this step. +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib + +from delivery.index.generate_mcp_snapshot import publish_snapshot +from runtime.v8std_mcp_snapshot_format import normalize_site_url + + +def local_config(text: str, site_url: str) -> str: + text = re.sub(r'^(repo_url|repo_name|edit_uri) = .*\n', '', text, flags=re.M) + text = re.sub(r'^site_url = .*$', 'site_url = ' + json.dumps(site_url), text, count=1, flags=re.M) + text = text.replace('[project.theme]\n', '[project.theme]\nfont = false\n') + text = re.sub(r'\[project.theme.font\]\n.*?(?=\n\[|\Z)', '', text, flags=re.S) + text = re.sub(r'\[project.extra.consent(?:\.[^\]]+)?\]\n.*?(?=\n\[|\Z)', '', text, flags=re.S) + text = text.replace('[project.extra]\n', '[project.extra]\nlocal_publication = true\n') + text = text.replace('[project.plugins.social]\nenabled = true', + '[project.plugins.social]\nenabled = false') + config = tomllib.loads(text)["project"] + assert config["theme"]["font"] is False + assert config["extra"]["local_publication"] is True + return text + + +def build_local_site(root: Path, output: Path, site_url: str, source_sha: str) -> Path: + root, output = root.resolve(), output.resolve() + # Never replace a checkout input, public output, ancestor, or existing result. + if output == root or output in root.parents or any( + output == root / name or root / name in output.parents + for name in ("docs", "scripts", "runtime", "delivery", "dev", "data", "overrides", "site", "spec", "LICENSES")): + raise ValueError("local output overlaps source or canonical site") + if output.exists(): + raise ValueError("local output must not exist") + site_url = normalize_site_url(site_url) + if not re.fullmatch(r"[0-9a-f]{40}", source_sha): + raise ValueError("source SHA must be 40 lowercase hexadecimal characters") + # All producers/build hooks run against this copy; no symlink back to inputs. + with tempfile.TemporaryDirectory(prefix="v8std-local-build-") as temporary: + stage = Path(temporary) + for name in ("docs", "scripts", "overrides", "LICENSES"): + shutil.copytree(root / name, stage / name, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + for name in ("zensical.toml", "retrieval-rules.yml", "LICENSE"): + shutil.copy2(root / name, stage / name) + env = {**os.environ, "V8STD_REPO_ROOT": str(stage), "PYTHONPATH": str(stage)} + def run(*args: str): + subprocess.run([sys.executable, *args], cwd=stage, env=env, check=True) + + # Generate Markdown sidecars against canonical config without overwriting + # any AI inputs. Snapshot bytes are the unchanged canonical producer's. + run("-c", "from pathlib import Path; from scripts.generate_ai_artifacts import " + "build_site_ai_index, write_site_markdown_pages; " + "write_site_markdown_pages(build_site_ai_index(Path.cwd()), Path('sidecars'))") + canonical = tomllib.loads((stage / "zensical.toml").read_text())["project"]["site_url"] + publish_snapshot(stage / "docs", stage / "snapshot", source_sha, canonical) + (stage / "zensical.toml").write_text(local_config( + (stage / "zensical.toml").read_text(), site_url), encoding="utf-8") + run("-m", "zensical", "build", "--strict") + # Do not run the unrelated article-HTML protection gate here; the final + # public strict wrapper retains that gate. This task checks local egress. + shutil.copytree(stage / "sidecars", stage / "site", dirs_exist_ok=True) + shutil.copytree(stage / "snapshot", stage / "site/ai/mcp/v1", dirs_exist_ok=True) + run("scripts/publish_license_texts.py", "--root", str(stage), "--site", str(stage / "site")) + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(stage / "site", output) + return output + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--site-url", required=True) + parser.add_argument("--source-sha", required=True) + args = parser.parse_args() + print(build_local_site(args.root, args.output, args.site_url, args.source_sha)) + + +if __name__ == "__main__": + main() diff --git a/delivery/site/site.conf b/delivery/site/site.conf new file mode 100644 index 0000000..07eb206 --- /dev/null +++ b/delivery/site/site.conf @@ -0,0 +1,50 @@ +worker_processes 1; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; +events { worker_connections 512; } +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + client_body_temp_path /tmp/client; + proxy_temp_path /tmp/proxy; + fastcgi_temp_path /tmp/fastcgi; + uwsgi_temp_path /tmp/uwsgi; + scgi_temp_path /tmp/scgi; + # Optional MCP HTTP profile, reached only through the published loopback port. + # Deferred DNS permits a site-only launch with no MCP container. + server { + listen 8001; + resolver 127.0.0.11 valid=10s ipv6=off; + location / { + set $mcp_backend mcp:8000; + proxy_pass http://$mcp_backend; + proxy_set_header Host $http_host; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 65s; + client_max_body_size 512k; + } + } + server { + listen 8000; + server_name _; + root /srv/site; + index index.html; + autoindex off; + # Match under any operator-selected site prefix. Never cache a pointer + # based on normalized mtimes, or a 404 for an immutable object. + location ~ /ai/mcp/v1/manifest\.json$ { + etag off; + if_modified_since off; + add_header Cache-Control "no-store" always; + try_files $uri =404; + } + location ~ "/ai/mcp/v1/[0-9a-f]{64}/snapshot\.tar\.gz$" { + types { application/gzip gz; } + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + location / { try_files $uri $uri/ =404; } + } +} diff --git a/delivery/vps/__init__.py b/delivery/vps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/delivery/vps/legacy-release-guard.conf b/delivery/vps/legacy-release-guard.conf new file mode 100644 index 0000000..cd6e21e --- /dev/null +++ b/delivery/vps/legacy-release-guard.conf @@ -0,0 +1,6 @@ +[Unit] +Requires=v8std-bootstrap-recover.timer +After=v8std-bootstrap-recover.timer + +[Service] +ExecCondition=+/usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py _legacy-allowed diff --git a/delivery/vps/nginx/edge-http.conf b/delivery/vps/nginx/edge-http.conf new file mode 100644 index 0000000..9ccf397 --- /dev/null +++ b/delivery/vps/nginx/edge-http.conf @@ -0,0 +1,9 @@ +# Include once in nginx http{}. Conservative LOCAL TEST starting values only; +# activation requires the recorded native shared-host mixed-load gate. +limit_conn_zone $server_name zone=v8std_active:1m; +limit_conn_zone $server_name zone=v8std_downloads:1m; +upstream v8std_release_runtime { + zone v8std_release_runtime 64k; + include /etc/nginx/v8std-release/upstream.conf; + keepalive 2; +} diff --git a/delivery/vps/nginx/edge-locations.conf b/delivery/vps/nginx/edge-locations.conf new file mode 100644 index 0000000..334b60e --- /dev/null +++ b/delivery/vps/nginx/edge-locations.conf @@ -0,0 +1,65 @@ +# Include in the separately inventoried ai.v8std.ru TLS server; do not replace +# default vhosts, certificate configuration or renewal during release. +location = /monitoring { + add_header Cache-Control "no-store" always; + return 410; +} +location ^~ /monitoring/ { + add_header Cache-Control "no-store" always; + return 410; +} +location = /mcp { + if ($request_method !~ ^(POST|HEAD)$) { return 405; } + limit_conn v8std_active 8; + limit_conn_status 429; + client_max_body_size 2m; + client_body_timeout 10s; + proxy_connect_timeout 2s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_buffering off; + proxy_pass http://v8std_release_runtime; + proxy_intercept_errors on; + error_page 429 = @v8std_busy; + error_page 502 503 504 = @v8std_unavailable; + add_header Allow "POST, HEAD" always; +} +location = /healthz { + proxy_connect_timeout 2s; + proxy_read_timeout 3s; + proxy_pass http://v8std_release_runtime; +} +location = /version { + proxy_connect_timeout 2s; + proxy_read_timeout 3s; + proxy_pass http://v8std_release_runtime; +} +location ~ "^/indexes/v1/([0-9a-f]{64})/snapshot[.]tar[.]gz$" { + alias /srv/v8std-indexes/v1/$1/snapshot.tar.gz; + limit_except GET { deny all; } + autoindex off; + types { } + default_type application/gzip; + gzip off; + etag on; + sendfile on; + limit_conn v8std_downloads 2; + limit_conn_status 429; + limit_rate 1m; + send_timeout 10s; + # Never use 'always': 403/404/429 must not acquire immutable caching. + add_header Cache-Control "public, max-age=31536000, immutable"; + error_page 429 = @v8std_busy; +} +location /indexes/ { return 404; } +location @v8std_busy { + add_header Retry-After 1 always; + return 429; +} +location @v8std_unavailable { + add_header Retry-After 1 always; + return 503; +} diff --git a/delivery/vps/release-entry.py b/delivery/vps/release-entry.py new file mode 100644 index 0000000..71bfe0c --- /dev/null +++ b/delivery/vps/release-entry.py @@ -0,0 +1,12 @@ +#!/usr/bin/python3 -I +"""Root-owned SSH forced command; no shell, SCP, SFTP or argument passthrough.""" +import os +import sys + +PUBLIC = {"validate-envelope", "deploy", "recover", "status", "publish-index"} +command = os.environ.get("SSH_ORIGINAL_COMMAND", "") +if command not in PUBLIC or len(sys.argv) != 1: + raise SystemExit("restricted command") +os.execve("/usr/bin/sudo", ["sudo", "-n", "/usr/bin/python3", "-I", + "/opt/v8std-release/delivery/vps/v8std_mcp_release.py", command], + {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"}) diff --git a/delivery/vps/release-policy.example.json b/delivery/vps/release-policy.example.json new file mode 100644 index 0000000..db5e40f --- /dev/null +++ b/delivery/vps/release-policy.example.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "enabled": false, + "runtime_enabled": false, + "platform": "linux/amd64", + "public_url": "https://ai.v8std.ru", + "ports": [18766, 18767], + "nginx_include": "/etc/nginx/v8std-release/upstream.conf", + "static_root": "/srv/v8std-indexes/v1", + "configs": {}, + "capacity": { + "disk_bytes": 3221225472, + "available_memory_bytes": 1073741824, + "file_descriptors": 4096, + "network_evidence": null + } +} diff --git a/delivery/vps/release.schema.json b/delivery/vps/release.schema.json new file mode 100644 index 0000000..1f8ddd7 --- /dev/null +++ b/delivery/vps/release.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:v8std:release:1", + "title": "Restricted v8std runtime release envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "release_id", "sequence", "runtime_source_sha", "trigger_sha", "image", "image_digest", "platform_digest", "configuration_digest", "corpus_id", "archive_sha256", "deadline"], + "properties": { + "schema_version": {"const": 1}, + "release_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,63}$"}, + "sequence": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, + "runtime_source_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "trigger_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "image": {"const": "ghcr.io/zeegin/v8std-mcp"}, + "image_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "platform_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "configuration_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "corpus_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "archive_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "deadline": {"type": "integer", "description": "UTC epoch seconds, future and at most 300 seconds from validation. Host additionally reserves recovery time."} + } +} diff --git a/delivery/vps/release.sudoers b/delivery/vps/release.sudoers new file mode 100644 index 0000000..04465db --- /dev/null +++ b/delivery/vps/release.sudoers @@ -0,0 +1,8 @@ +# Install with visudo -cf validation. No wildcards or editable interpreter paths. +Cmnd_Alias V8STD_RELEASE = /usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py validate-envelope, \ + /usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py deploy, \ + /usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py recover, \ + /usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py status, \ + /usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py publish-index +v8std-publisher ALL=(root) NOPASSWD: V8STD_RELEASE +Defaults!V8STD_RELEASE !setenv diff --git a/delivery/vps/v8std-bootstrap-recover.service b/delivery/vps/v8std-bootstrap-recover.service new file mode 100644 index 0000000..d55a896 --- /dev/null +++ b/delivery/vps/v8std-bootstrap-recover.service @@ -0,0 +1,16 @@ +[Unit] +Description=Recover operator v8std first migration independently of SSH +After=docker.service nginx.service network-online.target +Wants=network-online.target + +[Service] +Type=exec +ExecStart=/usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py bootstrap-recover +RuntimeMaxSec=300s +TimeoutStopSec=5s +KillMode=control-group +UMask=0077 +LimitNOFILE=4096 +PrivateTmp=yes +NoNewPrivileges=yes +ProtectHome=yes diff --git a/delivery/vps/v8std-bootstrap-recover.timer b/delivery/vps/v8std-bootstrap-recover.timer new file mode 100644 index 0000000..2995cb0 --- /dev/null +++ b/delivery/vps/v8std-bootstrap-recover.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Independent first-migration recovery guard + +[Timer] +OnBootSec=5s +OnUnitInactiveSec=15s +Unit=v8std-bootstrap-recover.service + +[Install] +WantedBy=timers.target diff --git a/delivery/vps/v8std-release-recover.service b/delivery/vps/v8std-release-recover.service new file mode 100644 index 0000000..72ac2d1 --- /dev/null +++ b/delivery/vps/v8std-release-recover.service @@ -0,0 +1,19 @@ +[Unit] +Description=Reconcile v8std release journal and durable inbox +After=docker.service nginx.service network-online.target +Wants=network-online.target +ConditionPathExists=/etc/v8std-release/policy.json + +[Service] +Type=exec +ExecStart=/usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py _recover +RuntimeMaxSec=300s +TimeoutStopSec=5s +KillMode=control-group +UMask=0077 +LimitNOFILE=4096 +PrivateTmp=yes +NoNewPrivileges=yes +ProtectHome=yes +# Host-owned job intentionally needs narrow Docker/nginx authority. The SSH +# identity has no Docker group/socket access and can invoke only fixed verbs. diff --git a/delivery/vps/v8std-release-recover.timer b/delivery/vps/v8std-release-recover.timer new file mode 100644 index 0000000..cea77a6 --- /dev/null +++ b/delivery/vps/v8std-release-recover.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Recover interrupted v8std releases independently of SSH/CI + +[Timer] +OnBootSec=15s +OnUnitInactiveSec=30s +Unit=v8std-release-recover.service + +[Install] +WantedBy=timers.target diff --git a/delivery/vps/v8std_mcp_release.py b/delivery/vps/v8std_mcp_release.py new file mode 100644 index 0000000..a101b9a --- /dev/null +++ b/delivery/vps/v8std_mcp_release.py @@ -0,0 +1,2156 @@ +"""Restricted host release transaction. Install reviewed code as root-owned files. + +The public CLI has no path, environment, command, trust-policy or mount options. +Adapters own all external effects. Journals precede effects; deterministic object +names let recovery reconcile Docker operations whose CLI was killed mid-call. +This file is deliberately absent from the runtime image. +""" +from __future__ import annotations + +from contextlib import contextmanager +import fcntl +import hashlib +import json +import multiprocessing +import os +from pathlib import Path +import re +import select +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +# The root-owned forced command is executed with Python -I. +# Add only its installation root, never cwd or an environment-provided path. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from runtime.v8std_mcp_snapshot_format import ( + MAX_ARCHIVE_BYTES, canonical_json, strict_json, validate_manifest, verify_archive, +) + +IMAGE = "ghcr.io/zeegin/v8std-mcp" +REPO = "zeegin/v8std" +WORKFLOW = "zeegin/v8std/.github/workflows/ci.yml" +REF = "refs/heads/main" +ROOT = Path("/var/lib/v8std-release") +POLICY = Path("/etc/v8std-release/policy.json") +INSTALL = Path("/opt/v8std-release/delivery/vps/v8std_mcp_release.py") +LEGACY_UNIT = "v8std-mcp.service" +LEGACY_APP = Path("/opt/v8std-mcp") +LEGACY_DATA = Path("/var/lib/v8std-mcp") +USAGE_LOG = Path("/var/log/v8std-mcp/tool-usage.jsonl") +USAGE_LOG_TARGET = "/var/log/v8std-mcp-usage.jsonl" +LEGACY_CONFIG = Path("/etc/systemd/system/v8std-mcp.service") +LEGACY_PYTHON = Path("/usr/bin/python3.12") +LEGACY_CACHE = {"pages.jsonl", "search-vectors.jsonl", "llms.txt", "llms-full.txt"} +RESTORE_STAGE = ".v8std-release-restore-v1" +BOOTSTRAP_WINDOW = Path("/etc/v8std-release/bootstrap.json") +INITIAL_INSTALL_AUTH = Path("/etc/v8std-release/initial-install.json") +MAINTENANCE_UPSTREAM = b"server 127.0.0.1:9 down;\n" +LEGACY_GUARD = ("[Unit]\nRequires=v8std-bootstrap-recover.timer\nAfter=v8std-bootstrap-recover.timer\n" + "\n[Service]\nExecCondition=+/usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py _legacy-allowed\n") +BOOTSTRAP_SERVICE = ("[Unit]\nDescription=Recover operator v8std first migration independently of SSH\n" + "After=docker.service nginx.service network-online.target\nWants=network-online.target\n\n" + "[Service]\nType=exec\nExecStart=/usr/bin/python3 -I /opt/v8std-release/delivery/vps/v8std_mcp_release.py bootstrap-recover\n" + "RuntimeMaxSec=300s\nTimeoutStopSec=5s\nKillMode=control-group\nUMask=0077\nLimitNOFILE=4096\n" + "PrivateTmp=yes\nNoNewPrivileges=yes\nProtectHome=yes\n") +BOOTSTRAP_TIMER = ("[Unit]\nDescription=Independent first-migration recovery guard\n\n[Timer]\n" + "OnBootSec=5s\nOnUnitInactiveSec=15s\nUnit=v8std-bootstrap-recover.service\n\n" + "[Install]\nWantedBy=timers.target\n") +TRANSACTION = 300 +READINESS = 90 +SMOKE = DRAIN = 30 +STOP = 45 +# Rollback gets a live budget even if preparation uses its entire work allowance. +# 90 ready + 30 smoke + 45 stop + 15 nginx/control overhead. +RECOVERY_RESERVE = 180 +INITIAL_RECOVERY_RESERVE = 60 +TERMINAL = {"FAILED", "ROLLED_BACK", "COMMITTED", "RECOVERY_REQUIRED"} +ID = re.compile(r"[a-z0-9][a-z0-9-]{0,63}\Z") +HEX = re.compile(r"[0-9a-f]{64}\Z") +SHA = re.compile(r"[0-9a-f]{40}\Z") +DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +INDEX_TYPES = {"application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json"} +MANIFEST_TYPES = {"application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"} +CONFIG_TYPES = {"application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json"} + + +class ReleaseError(ValueError): + """Codes only; raw external errors and command output never enter status.""" + + def __init__(self, code): + self.code = code + super().__init__(code) + + +def require(condition, code): + if not condition: + raise ReleaseError(code) + + +def matches(pattern, value): + return isinstance(value, str) and bool(pattern.fullmatch(value)) + + +def digest(raw): + return hashlib.sha256(raw).hexdigest() + + +def parse(raw, limit=8192): + require(len(raw) <= limit, "input_size") + try: + value = strict_json(raw) + require(isinstance(value, dict), "input_shape") + return value + except ValueError: + raise ReleaseError("input_shape") from None + + +def validate_envelope(raw, *, now=None, expired=False): + value = parse(raw) + require(set(value) == {"schema_version", "release_id", "sequence", "runtime_source_sha", + "trigger_sha", "image", "image_digest", "platform_digest", "configuration_digest", + "corpus_id", "archive_sha256", "deadline"}, "envelope_fields") + require(type(value["schema_version"]) is int and value["schema_version"] == 1, "schema") + require(matches(ID, value["release_id"]), "release_id") + require(type(value["sequence"]) is int and 0 < value["sequence"] <= 2**53 - 1, "sequence") + require(value["image"] == IMAGE, "namespace") + for key in ("runtime_source_sha", "trigger_sha"): + require(matches(SHA, value[key]), key) + for key in ("image_digest", "platform_digest"): + require(matches(DIGEST, value[key]), key) + for key in ("configuration_digest", "corpus_id", "archive_sha256"): + require(matches(HEX, value[key]), key) + require(type(value["deadline"]) is int, "deadline") + if not expired: + now = time.time() if now is None else now + require(now < value["deadline"] <= now + TRANSACTION, "deadline") + return value + + +def read_file(path, limit=65536): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_size <= limit, "file_shape") + raw = stream.read(limit + 1) + require(len(raw) <= limit, "file_size") + return raw + + +def sync_dir(path): + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def ensure_directory(path, mode=0o700): + if not path.exists(): + ensure_directory(path.parent) + path.mkdir(mode=mode, exist_ok=True) + sync_dir(path.parent) + require(stat.S_ISDIR(path.lstat().st_mode), "directory_shape") + + +def atomic(path, raw, *, mode=0o600): + ensure_directory(path.parent) + fd, name = tempfile.mkstemp(prefix=".write-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(raw) + stream.flush() + os.fchmod(stream.fileno(), mode) + os.fsync(stream.fileno()) + os.replace(name, path) + sync_dir(path.parent) + finally: + Path(name).unlink(missing_ok=True) + + +def write_json(path, value, *, mode=0o600): + # Operational timestamps/health contain finite floats; snapshot descriptors + # and envelope hashes keep the separate float-free canonical encoding. + atomic(path, json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode(), mode=mode) + + +def read_record(path): + # The wire header remains <=64KiB; a durable receipt wraps it with state. + # This host-owned record limit must not accidentally reapply the 8KiB + # release-envelope limit to valid corpus manifests or publication receipts. + return parse(read_file(path, 128 * 1024), 128 * 1024) + + +@contextmanager +def locked(root): + ensure_directory(root) + fd = os.open(root / "release.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise ReleaseError("busy") from None + yield + finally: + os.close(fd) + + +def remaining(deadline, cap=None): + value = deadline - time.monotonic() + require(value > 0, "deadline") + return min(value, cap) if cap is not None else value + + +def run(argv, deadline, *, limit=2 * 1024 * 1024): + """No shell. Kill/reap the CLI process group; daemon effects need reconciliation.""" + with tempfile.TemporaryFile() as output: + process = subprocess.Popen([str(x) for x in argv], stdin=subprocess.DEVNULL, + stdout=output, stderr=subprocess.DEVNULL, start_new_session=True, + env={"PATH": "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", "HOME": "/var/lib/v8std-release"}) + try: + while process.poll() is None: + require(output.tell() <= limit, "command_output") + time.sleep(min(.025, remaining(deadline))) + require(process.returncode == 0, "command_failed") + require(output.tell() <= limit, "command_output") + output.seek(0) + return output.read(limit + 1) + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + + +def trusted_policy(path=POLICY): + # Reject symlinked/writable policy and all parents before interpreting paths. + trusted_path(path) + policy = parse(read_file(path), 65536) + return validate_policy(policy) + + +def trusted_path(path): + for entry in (path, *path.parents): + info = entry.lstat() + require(not stat.S_ISLNK(info.st_mode) and info.st_uid == 0 and not info.st_mode & 0o022, + "policy_permissions") + + +def prepare_usage_log(*, create=True): + """Provision only the fixed private inode; never repair/truncate history.""" + parent = USAGE_LOG.parent + trusted_path(parent.parent) + try: + try: + parent.mkdir(mode=0o700) + os.chown(parent, 0, 0) + sync_dir(parent.parent) + except FileExistsError: + pass + directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + info = os.fstat(directory) + require((info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) == (0, 0, 0o700), "usage_log_directory") + flags = os.O_NOFOLLOW | os.O_NONBLOCK + try: + fd = os.open(USAGE_LOG.name, os.O_RDONLY | flags, dir_fd=directory) + except FileNotFoundError: + require(create, "usage_log_missing") + fd = os.open(USAGE_LOG.name, os.O_RDWR | os.O_CREAT | os.O_EXCL | flags, 0o600, dir_fd=directory) + try: + os.fchown(fd, 10001, 0) + os.fchmod(fd, 0o640) # Explicit final mode despite UMask0077. + os.fsync(fd) + os.fsync(directory) + except BaseException: + os.close(fd) + raise + try: + info = os.fstat(fd) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 + and (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) == (10001, 0, 0o640), "usage_log_file") + finally: + os.close(fd) + finally: + os.close(directory) + except OSError: + raise ReleaseError("usage_log_file") from None + + +def check_usage_binding(info): + """Start-only guard: a malformed logging profile cannot forbid owned stop.""" + mounts = info.get("Mounts") or [] + selected = [mount for mount in mounts if mount.get("Destination") == USAGE_LOG_TARGET] + require(len(selected) == 1 and selected[0].get("Type") == "bind" + and selected[0].get("Source") == str(USAGE_LOG) and selected[0].get("RW") is True, "usage_log_binding") + for mount in mounts: + if mount is selected[0]: + continue + source = Path(mount.get("Source", "")) + require(source not in USAGE_LOG.parents and not source.is_relative_to(USAGE_LOG.parent) + and Path(mount.get("Destination", "")) not in Path(USAGE_LOG_TARGET).parents, "usage_log_exposure") + command = info.get("Config", {}).get("Cmd") or [] + flags = [i for i, arg in enumerate(command) if arg == "--usage-log" or arg.startswith("--usage-log=")] + require(len(flags) == 1 and command[flags[0]:flags[0] + 2] == ["--usage-log", USAGE_LOG_TARGET], "usage_log_argument") + + +def validate_bootstrap_window(window, envelope, *, recovery=False): + require(set(window) == {"schema_version", "start_utc", "end_utc", "return_reserve_seconds", + "envelope_sha256", "mode", "legacy_unit", "legacy_source_sha", "backup_manifest_sha256", + "capacity"}, "bootstrap_fields") + require(type(window["schema_version"]) is int and window["schema_version"] == 1, "schema") + for key in ("start_utc", "end_utc", "return_reserve_seconds"): + require(type(window[key]) is int, "bootstrap_window") + duration = window["end_utc"] - window["start_utc"] + require(0 < duration <= 7200 and 1800 <= window["return_reserve_seconds"] < duration, "bootstrap_window") + require(window["envelope_sha256"] == digest(canonical_json(envelope)), "bootstrap_envelope") + require(window["legacy_unit"] == LEGACY_UNIT and window["mode"] in {"overlap", "stop-start"}, "bootstrap_target") + require(matches(SHA, window["legacy_source_sha"]) and matches(HEX, window["backup_manifest_sha256"]), "bootstrap_identity") + capacity = window["capacity"] + require(isinstance(capacity, dict) and set(capacity) == { + "disk_bytes", "available_memory_bytes", "file_descriptors", "network_evidence"}, "capacity") + require(all(type(capacity[k]) is int and capacity[k] > 0 for k in ( + "disk_bytes", "available_memory_bytes", "file_descriptors")) + and matches(HEX, capacity["network_evidence"]), "capacity") + if not recovery: + now = time.time() + require(window["start_utc"] <= now < window["end_utc"] - window["return_reserve_seconds"], "bootstrap_window") + require(envelope["deadline"] <= window["end_utc"] - window["return_reserve_seconds"], "bootstrap_window") + return window + + +def legacy_start_allowed(root): + # ExecCondition is fail-closed even with corrupt journal or missing active.json. + # An enabled legacy unit must never race accepted Docker recovery at boot. + if (Path(root) / "active.json").exists(): + return False + records = Controller(root, None).journals() + if any(item["state"] == "COMMITTED" for item in records): + return False + if not records: + return True + latest = max(records, key=lambda item: item["envelope"]["sequence"]) + return latest.get("kind") != "bootstrap" or latest.get("legacy_start_allowed") is True + + +def file_hash(path, deadline): + hashed = hashlib.sha256() + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + require(stat.S_ISREG(os.fstat(stream.fileno()).st_mode), "backup_file") + while chunk := stream.read(65536): + remaining(deadline) + hashed.update(chunk) + return hashed.hexdigest() + + +def restore_file(source, target, entry, deadline): + """Fresh destination mtime is essential for the original legacy cache TTL. + + Stream, hash, fchown/fchmod and fsync before rename. No copy2/stale timestamps. + Usage logs are never enumerated, copied, logged or replaced here. + """ + require(file_hash(source, deadline) == entry["sha256"], "backup_hash") + ensure_directory(target.parent) + # Same-filesystem private staging survives SIGKILL without being confused + # with unlisted application code. One deterministic owned slot per target; + # retry truncates only that protected regular file, never a link or path + # received from a caller. No wildcard removal or cleanup of legacy files. + staging = target.parent / RESTORE_STAGE + ensure_directory(staging) + info = staging.lstat() + require(info.st_uid == os.geteuid() and info.st_mode & 0o777 == 0o700, "restore_staging") + name = staging / digest(str(target).encode()) + fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600) + try: + with os.fdopen(fd, "wb") as output, source.open("rb") as input_file: + info = os.fstat(output.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, "restore_staging") + output.truncate(0) + while chunk := input_file.read(65536): + remaining(deadline) + output.write(chunk) + output.flush() + os.fchown(output.fileno(), entry["uid"], entry["gid"]) + os.fchmod(output.fileno(), entry["mode"]) + os.fsync(output.fileno()) + require(file_hash(Path(name), deadline) == entry["sha256"], "restore_hash") + os.replace(name, target) + sync_dir(target.parent) + sync_dir(staging) + finally: + Path(name).unlink(missing_ok=True) + + +def backup_inventory(root, window, deadline): + """Root-owned, bounded full app manifest; roots are compiled, never supplied.""" + backup = root / "legacy" + path = backup / "manifest.json" + trusted_path(path) + raw = read_file(path, 16 * 1024 * 1024) + require(digest(raw) == window["backup_manifest_sha256"], "backup_manifest") + value = parse(raw, 16 * 1024 * 1024) + require(set(value) == {"schema_version", "app", "cache", "directories", "unit", "upstream", "interpreter_sha256"} + and type(value["schema_version"]) is int and value["schema_version"] == 1, "backup_fields") + require(matches(HEX, value["interpreter_sha256"]), "backup_interpreter") + require(set(value["cache"]) == LEGACY_CACHE and isinstance(value["app"], dict) + and 1 <= len(value["app"]) <= 20000, "backup_files") + require({"scripts/v8std_mcp_server.py", "scripts/v8std_mcp_index.py", "scripts/v8std_retrieval_rules.py", + "venv/pyvenv.cfg", "venv/bin/python"} <= set(value["app"]), "backup_incomplete") + for component in ("app", "cache"): + require(set(value["directories"]) == {"app", "cache"}, "backup_directories") + directories = value["directories"][component] + expected = {str(parent) for name in value[component] for parent in Path(name).parents} + require(set(directories) == expected, "backup_directories") + for entry in directories.values(): + require(set(entry) == {"mode", "uid", "gid"} and type(entry["mode"]) is int + and 0 <= entry["mode"] <= 0o777 and all(type(entry[k]) is int and + 0 <= entry[k] <= 2**31-1 for k in ("uid", "gid")), "backup_metadata") + for relative, entry in value[component].items(): + parts = relative.split("/") + require(len(relative) <= 512 and all(matches(re.compile(r"[A-Za-z0-9_.+@-]+\Z"), p) + and p not in {".", ".."} for p in parts), "backup_path") + require(isinstance(entry, dict), "backup_entry") + if "link" in entry: + require(component == "app" and set(entry) == {"link"}, "backup_link") + link = entry["link"] + require(isinstance(link, str) and len(link) <= 512, "backup_link") + destination = Path(os.path.normpath(str(LEGACY_APP / relative / ".." / link))) + require(destination == LEGACY_PYTHON or destination.is_relative_to(LEGACY_APP), "backup_link") + # Manifest links are data; protected backup contains no symlinks. + continue + require(set(entry) == {"sha256", "mode", "uid", "gid"}, "backup_entry") + require(matches(HEX, entry["sha256"]) and type(entry["mode"]) is int + and 0 <= entry["mode"] <= 0o777 and all(type(entry[k]) is int and + 0 <= entry[k] <= 2**31-1 for k in ("uid", "gid")), "backup_metadata") + source = backup / component / relative + trusted_path(source) + require(file_hash(source, deadline) == entry["sha256"], "backup_hash") + for name in ("unit", "upstream"): + require(set(value[name]) == {"sha256", "mode", "uid", "gid"} + and matches(HEX, value[name]["sha256"]) and value[name]["uid"] == 0 + and value[name]["gid"] == 0 and value[name]["mode"] in {0o600, 0o644}, "backup_config") + trusted_path(backup / name) + require(file_hash(backup / name, deadline) == value[name]["sha256"], "backup_hash") + return value + + +def validate_policy(policy): + require(set(policy) == {"schema_version", "enabled", "runtime_enabled", "platform", "public_url", "configs", + "capacity", "ports", "nginx_include", "static_root"}, "policy_fields") + require(type(policy["schema_version"]) is int and policy["schema_version"] == 1 + and policy["enabled"] is True, "not_activated") + require(type(policy["runtime_enabled"]) is bool, "runtime_activation") + require(policy["platform"] in {"linux/amd64", "linux/arm64"}, "platform") + require(policy["public_url"] == "https://ai.v8std.ru", "public_url") + require(policy["nginx_include"] == "/etc/nginx/v8std-release/upstream.conf", "nginx_path") + require(policy["static_root"] == "/srv/v8std-indexes/v1", "static_path") + require(policy["ports"] == [18766, 18767], "ports") + require(isinstance(policy["configs"], dict) and len(policy["configs"]) <= 16 + and (bool(policy["configs"]) or not policy["runtime_enabled"]), "configs") + for key, config in policy["configs"].items(): + require(matches(HEX, key) and digest(canonical_json(config)) == key, "configuration_digest") + require(set(config) == {"site_url", "refresh_seconds", "max_snippet_chars", "memory_bytes", "cpus"}, "config_fields") + require(config["site_url"] == "https://v8std.ru/", "site_url") + for field, low, high in (("refresh_seconds", 0, 86400), ("max_snippet_chars", 4000, 32000), + ("memory_bytes", 268435456, 8589934592), ("cpus", 1, 64)): + require(type(config[field]) is int and low <= config[field] <= high, "config_value") + capacity = policy["capacity"] + require(set(capacity) == {"disk_bytes", "available_memory_bytes", "file_descriptors", "network_evidence"}, "capacity") + for name in ("disk_bytes", "available_memory_bytes", "file_descriptors"): + require(type(capacity[name]) is int and capacity[name] > 0, "capacity") + if policy["runtime_enabled"]: + require(matches(HEX, capacity["network_evidence"]), "capacity_evidence") + require(capacity["available_memory_bytes"] >= max(c["memory_bytes"] for c in policy["configs"].values()) + 128 * 1024 * 1024, + "capacity_reserve") + else: + require(capacity["network_evidence"] is None or matches(HEX, capacity["network_evidence"]), "capacity_evidence") + return policy + + +def verify_descriptors(index_raw, child_raw, envelope, platform): + def decoded(raw, expected): + # buildx adds a display newline on some versions; only discard it if + # the exact remaining bytes match the requested content address. + if "sha256:" + digest(raw) != expected and raw.endswith(b"\n"): + raw = raw[:-1] + require("sha256:" + digest(raw) == expected, "descriptor_digest") + return parse(raw, 2 * 1024 * 1024) + index = decoded(index_raw, envelope["image_digest"]) + child = decoded(child_raw, envelope["platform_digest"]) + require(index.get("mediaType") in INDEX_TYPES and index.get("schemaVersion") == 2, "index_media_type") + require(child.get("mediaType") in MANIFEST_TYPES and child.get("schemaVersion") == 2, "child_media_type") + os_name, architecture = platform.split("/") + members = [item for item in index.get("manifests", []) if item.get("digest") == envelope["platform_digest"] + and item.get("mediaType") in MANIFEST_TYPES + and item.get("platform", {}).get("os") == os_name + and item.get("platform", {}).get("architecture") == architecture + and item.get("platform", {}).get("variant", "") in ({"", "v8"} if architecture == "arm64" else {""})] + require(len(members) == 1, "platform_membership") + require(members[0].get("size") in {len(child_raw), len(child_raw.rstrip(b"\n"))}, "descriptor_size") + config = child.get("config", {}) + require(config.get("mediaType") in CONFIG_TYPES and matches(DIGEST, config.get("digest")), "config_descriptor") + return {envelope["image_digest"]: index["mediaType"], envelope["platform_digest"]: child["mediaType"], + config["digest"]: config["mediaType"]} + + +def attestation_command(subject, source_sha): + return ["gh", "attestation", "verify", subject, "--repo", REPO, "--signer-workflow", WORKFLOW, + "--source-ref", REF, "--source-digest", source_sha, "--deny-self-hosted-runners", + "--cert-oidc-issuer", "https://token.actions.githubusercontent.com", "--format", "json"] + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + raise ReleaseError("http_redirect") + + +def _http(url, deadline, body, limit, maintenance=False): + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + request = urllib.request.Request(url, data=body, headers={ + "Content-Type": "application/json", "Accept": "application/json, text/event-stream", + "Accept-Encoding": "identity"}) + try: + try: + response = opener.open(request, timeout=remaining(deadline, 3)) + except urllib.error.HTTPError as error: + # urllib raises for the very 503 that establishes maintenance. + response = error + with response: + require(response.status == (503 if maintenance else 200), "http_status") + if maintenance: + retry = response.headers.get("Retry-After", "") + require(retry.isdigit() and 0 < int(retry) <= 300, "maintenance_retry") + raw = response.read(limit + 1) + require(len(raw) <= limit, "http_size") + remaining(deadline) + return raw + except (OSError, urllib.error.URLError): + raise ReleaseError("http_failed") from None + + +def _http_worker(channel, url, deadline, body, limit, maintenance=False): + try: + channel.send_bytes(b"1" + _http(url, deadline, body, limit, maintenance)) + except Exception: + channel.send_bytes(b"0") + finally: + channel.close() + + +def http(url, deadline, *, body=None, limit=1024 * 1024, maintenance=False): + # DNS and drip-fed bodies cannot spend rollback's reserved time. This process + # has no host effects and is killed/reaped at the caller's actual deadline. + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe(duplex=False) + process = context.Process(target=_http_worker, args=(child, url, deadline, body, limit, maintenance), daemon=True) + process.start() + child.close() + try: + require(parent.poll(remaining(deadline)), "deadline") + raw = parent.recv_bytes(limit + 1) + remaining(deadline) + require(raw[:1] == b"1", "http_failed") + return raw[1:] + except (EOFError, OSError): + raise ReleaseError("http_failed") from None + finally: + parent.close() + if process.is_alive(): + process.kill() + process.join() + process.close() + + +def _rpc_reply(url, method, params, deadline, number, *, limit=1024 * 1024): + raw = http(url + "/mcp", deadline, body=canonical_json({"jsonrpc": "2.0", "id": number, + "method": method, "params": params}), limit=limit) + if raw.startswith(b"event:") or raw.startswith(b"data:"): + messages = [line[6:] for line in raw.splitlines() if line.startswith(b"data: ")] + require(len(messages) == 1, "rpc_stream") + raw = messages[0] + reply = parse(raw, limit) + require(reply.get("jsonrpc") == "2.0" and type(reply.get("id")) is type(number) + and reply["id"] == number, "rpc") + return reply + + +def rpc(url, method, params, deadline, number, *, limit=1024 * 1024): + reply = _rpc_reply(url, method, params, deadline, number, limit=limit) + require("error" not in reply and isinstance(reply.get("result"), dict), "rpc") + result = reply["result"] + require(not result.get("isError"), "rpc_tool") + return result + + +def smoke(url, record, deadline): + health = parse(http(url + "/healthz", deadline)) + require(health.get("ok") is True and health.get("runtime_sha") == record["runtime_source_sha"] + and health.get("corpus_id") == record["corpus_id"] + and health.get("archive_sha256") == record["archive_sha256"] + and health.get("hold_token") == record["hold_token"], "health_identity") + initialized = rpc(url, "initialize", {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "v8std-release", "version": "1"}}, deadline, 1) + require(initialized.get("serverInfo", {}).get("name") == "v8std", "server_identity") + capabilities = initialized.get("capabilities") + require(isinstance(capabilities, dict) and "resources" not in capabilities, "resource_capability") + listed = rpc(url, "tools/list", {}, deadline, 2) + expected = {"v8std_search", "v8std_get_page", "v8std_get_related", "v8std_explain_snippet", "v8std_explain_diagnostics"} + require({item["name"] for item in listed.get("tools", [])} == expected, "tool_surface") + result = rpc(url, "tools/call", {"name": "v8std_search", "arguments": {"query": "std437", "limit": 1}}, deadline, 3) + # Successful JSON-RPC alone cannot establish a useful search/page response. + def structured(value): + if "structuredContent" in value: + return value["structuredContent"] + texts = [item["text"] for item in value.get("content", []) if item.get("type") == "text"] + require(len(texts) == 1, "tool_content") + return parse(texts[0].encode(), 1024 * 1024) + search = structured(result) + require(bool(search.get("results")), "search_empty") + page_id = search["results"][0]["id"] + page = structured(rpc(url, "tools/call", {"name": "v8std_get_page", "arguments": + {"id_or_alias_or_url": page_id}}, deadline, 4)) + require(page.get("found") is True and page.get("page", {}).get("id") == page_id, "page_smoke") + structured(rpc(url, "tools/call", {"name": "v8std_explain_snippet", "arguments": + {"snippet": "Запрос = Новый Запрос;", "limit": 1}}, deadline, 5)) + structured(rpc(url, "tools/call", {"name": "v8std_get_related", "arguments": + {"id_or_alias_or_url": page_id, "limit": 1}}, deadline, 6)) + structured(rpc(url, "tools/call", {"name": "v8std_explain_diagnostics", "arguments": + {"codes": ["missing"]}}, deadline, 7)) + denied = _rpc_reply(url, "resources/read", {"uri": "v8std://llms-full.txt"}, deadline, 8) + # Only a compact error is allowed: no result or additional payload fields. + # The code carries the refusal semantics; message wording is not a contract. + error = denied.get("error") + require(set(denied) == {"jsonrpc", "id", "error"} and isinstance(error, dict) + and set(error) == {"code", "message"} and error["code"] == -32601, "resource_disabled") + message = error["message"] + require(isinstance(message, str) and bool(message.strip()) and len(message) <= 256, "resource_disabled") + # Bracket tool calls with the held identity so a health-only mismatch cannot + # pass while the actual endpoint refreshes or nginx reload serves old workers. + after = parse(http(url + "/healthz", deadline)) + require(all(after.get(k) == health.get(k) for k in ( + "runtime_sha", "corpus_id", "archive_sha256", "hold_token")), "smoke_generation_changed") + return health + + +class HostAdapter: + def __init__(self, root, policy): + self.root, self.policy = Path(root), policy + + def config(self, envelope): + config = self.policy["configs"].get(envelope["configuration_digest"]) + require(config is not None and digest(canonical_json(config)) == envelope["configuration_digest"], "untrusted_configuration") + return config + + def verify(self, envelope, deadline): + self.config(envelope) + run(attestation_command("oci://" + IMAGE + "@" + envelope["image_digest"], + envelope["runtime_source_sha"]), deadline) + # Certificate source-ref binds main at build time. Current eligibility of + # both runtime and triggering commits additionally requires main ancestry. + for sha in {envelope["runtime_source_sha"], envelope["trigger_sha"]}: + result = parse(run(["gh", "api", f"repos/{REPO}/compare/{sha}...main"], deadline), 2 * 1024 * 1024) + require(result.get("status") in {"ahead", "identical"} + and result.get("merge_base_commit", {}).get("sha") == sha, "main_ancestry") + index = run(["docker", "buildx", "imagetools", "inspect", "--raw", IMAGE + "@" + envelope["image_digest"]], deadline) + child = run(["docker", "buildx", "imagetools", "inspect", "--raw", IMAGE + "@" + envelope["platform_digest"]], deadline) + return verify_descriptors(index, child, envelope, self.policy["platform"]) + + def capacity(self, deadline, *, reclaim_bytes=0): + limits = self.policy["capacity"] + self._basic_capacity(deadline, limits["available_memory_bytes"], reclaim_bytes=reclaim_bytes) + evidence = read_file(self.root / "capacity" / (limits["network_evidence"] + ".json")) + require(digest(evidence) == limits["network_evidence"], "network_capacity") + remaining(deadline) + + def _basic_capacity(self, deadline, memory_bytes, *, reclaim_bytes=0): + limits = self.policy["capacity"] + require(shutil.disk_usage(self.root).free >= limits["disk_bytes"], "disk_capacity") + values = dict(re.findall(r"^(\w+):\s+(\d+)", read_file(Path("/proc/meminfo")).decode(), re.M)) + require(int(values.get("MemAvailable", 0)) * 1024 + reclaim_bytes >= memory_bytes, "memory_capacity") + import resource + require(resource.getrlimit(resource.RLIMIT_NOFILE)[0] >= limits["file_descriptors"], "fd_capacity") + remaining(deadline) + + def initial_capacity(self, candidate, deadline): + self._basic_capacity(deadline, self.config(candidate)["memory_bytes"] + 128 * 1024 * 1024) + + def initial_authorization(self, envelope): + trusted_path(INITIAL_INSTALL_AUTH) + authorization = parse(read_file(INITIAL_INSTALL_AUTH)) + require(set(authorization) == {"schema_version", "mode", "envelope_sha256", "allow_no_predecessor"} + and type(authorization["schema_version"]) is int and authorization["schema_version"] == 1 + and authorization["mode"] == "clean-host" and authorization["allow_no_predecessor"] is True + and authorization["envelope_sha256"] == digest(canonical_json(envelope)), "initial_authorization") + + def initial_host(self, journals, deadline): + # Absence of active.json is insufficient: keep all historical ownership + # evidence, including stopped containers and failed, cleaned journals. + for path in (self.root / "active.json", self.root / "predecessor.json", self.root / "pending-deploy.json", + LEGACY_APP, LEGACY_DATA, LEGACY_CONFIG): + require(not os.path.lexists(path), "initial_host_not_empty") + unit = dict(line.split("=", 1) for line in run(["systemctl", "show", LEGACY_UNIT, + "--property=LoadState", "--property=ActiveState", "--property=MainPID", "--property=FragmentPath"], + deadline).decode().splitlines()) + require(unit.get("LoadState") == "not-found" and unit.get("ActiveState") == "inactive" + and unit.get("MainPID") == "0" and unit.get("FragmentPath") == "", "initial_legacy_unit") + handoff = self.root / "handoff-import.json" + if os.path.lexists(handoff): + imported = read_record(handoff) + require(set(imported) == {"schema_version", "handoff_sha256", "state", "publication_sequence"} + and type(imported["schema_version"]) is int and imported["schema_version"] == 1 + and matches(HEX, imported["handoff_sha256"]) and imported["state"] == "COMMITTED" + and type(imported["publication_sequence"]) is int + and 0 <= imported["publication_sequence"] <= 2**53 - 1, "initial_handoff_incomplete") + owned = {} + for journal in journals: + validate_initial_journal(journal, self.policy) + require(journal["state"] == "FAILED" and journal.get("cleanup_complete") is True, + "initial_history") + candidate = journal["candidate"] + if candidate: + owned[candidate["name"]] = candidate + info = self.inspect(candidate, deadline) + require(info is None or info["State"]["Running"] is False, "initial_running_candidate") + for directory in (self.root / "releases", self.root / "rejected", self.root / "slots"): + if not os.path.lexists(directory): + continue + require(stat.S_ISDIR(directory.lstat().st_mode), "initial_directory") + allowed = ({j["envelope"]["release_id"] + ".json" for j in journals} if directory.name == "releases" else + {c["release_id"] for c in owned.values()} if directory.name == "slots" else set()) + # The current RECEIVED journal is checked by execute, not historical + # admission; its filename is still required to match its envelope. + for entry in directory.iterdir(): + if directory.name == "releases" and entry.name not in allowed: + current = read_record(entry) + validate_initial_journal(current, self.policy) + require(current["state"] == "RECEIVED" and current["candidate"] is None + and entry.name == current["envelope"]["release_id"] + ".json", "initial_history") + else: + require(entry.name in allowed and not entry.is_symlink(), "initial_unowned_path") + names = run(["docker", "ps", "-a", "--format", "{{.Names}}"], deadline).decode().splitlines() + for name in names: + require(bool(name) and not name.startswith("-"), "initial_container_inventory") + info = json.loads(run(["docker", "inspect", "--type", "container", name], deadline))[0] + labels = info["Config"].get("Labels") or {} + image = info["Config"].get("Image", "") + ports = info.get("HostConfig", {}).get("PortBindings") or {} + runtime = (name.startswith("v8std-") or any(key.startswith("pro.v8std.") for key in labels) + or image == IMAGE or image.startswith(IMAGE + "@") or image.startswith(IMAGE + ":") + or any(str(binding.get("HostPort")) in {str(p) for p in self.policy["ports"]} + for bindings in ports.values() for binding in (bindings or []))) + require(not runtime or name in owned, "initial_unowned_container") + path = Path(self.policy["nginx_include"]) + trusted_path(path) + require(read_file(path) == MAINTENANCE_UPSTREAM, "initial_upstream") + remaining(deadline) + + def maintenance(self, deadline): + path = Path(self.policy["nginx_include"]) + trusted_path(path) + managed = {MAINTENANCE_UPSTREAM} | { + f"server 127.0.0.1:{port} max_conns=8;\n".encode() for port in self.policy["ports"]} + require(read_file(path) in managed, "initial_upstream") + atomic(path, MAINTENANCE_UPSTREAM) + run(["nginx", "-t"], deadline) + run(["nginx", "-s", "reload"], deadline) + http(self.policy["public_url"] + "/mcp", deadline, body=b"{}", limit=65536, maintenance=True) + + def pull(self, record, deadline): + run(["docker", "pull", "--platform", self.policy["platform"], IMAGE + "@" + record["platform_digest"]], deadline) + + def inspect(self, record, deadline): + try: + result = json.loads(run(["docker", "inspect", "--type", "container", record["name"]], deadline)) + except ReleaseError as error: + if error.code != "command_failed": + raise + # Only confirmed absence can authorize creation, not inspect failure. + names = run(["docker", "ps", "-a", "--format", "{{.Names}}"], deadline).decode().splitlines() + require(record["name"] not in names, "inspect_failed") + return None + info = result[0] + labels = info["Config"].get("Labels") or {} + require(labels.get("pro.v8std.release") == record["release_id"] + and labels.get("pro.v8std.envelope") == record["envelope_hash"], "ownership") + require(info["Config"]["Image"] == IMAGE + "@" + record["platform_digest"], "container_image") + require(info["Image"] in record["descriptors"], "image_descriptor_identity") + require(labels.get("org.opencontainers.image.revision") == record["runtime_source_sha"], "runtime_revision") + return info + + def start(self, record, deadline): + info = self.inspect(record, deadline) + if info: + check_usage_binding(info) + prepare_usage_log(create=info is None) + if info: + if not info["State"]["Running"]: + run(["docker", "start", record["name"]], deadline) + return + config = self.config(record) + directory = self.root / "slots" / record["release_id"] + cache = directory / "cache" + ensure_directory(cache) + os.chown(cache, 10001, 10001) + # Dedicated cache/control paths are derived solely from the validated ID. + run(["docker", "run", "-d", "--name", record["name"], "--pull", "never", + "--label", "pro.v8std.release=" + record["release_id"], + "--label", "pro.v8std.envelope=" + record["envelope_hash"], + "--platform", self.policy["platform"], "--read-only", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--init", "--user", "10001:10001", + "--memory", str(config["memory_bytes"]), "--memory-swap", str(config["memory_bytes"]), + "--cpus", str(config["cpus"]), "--pids-limit", "128", "--stop-timeout", "30", + "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m,mode=1777", + "--mount", f"type=bind,source={cache},target=/var/lib/v8std-mcp", + "--mount", f"type=bind,source={directory / 'control'},target=/run/v8std-release,readonly", + "--mount", f"type=bind,source={USAGE_LOG},target={USAGE_LOG_TARGET}", + "-p", f"127.0.0.1:{record['port']}:8000", IMAGE + "@" + record["platform_digest"], + "--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000", + "--site-url", config["site_url"], "--refresh-seconds", str(config["refresh_seconds"]), + "--max-snippet-chars", str(config["max_snippet_chars"]), + "--usage-log", USAGE_LOG_TARGET], deadline) + self.inspect(record, deadline) + + def control(self, record, mode, token, manifest=None): + control_directory = self.root / "slots" / record["release_id"] / "control" + ensure_directory(control_directory, 0o755) + # mkdir's mode is filtered by the recovery service's UMask=0077. + # Publish final traversal/read permissions before making a command visible. + os.chmod(control_directory, 0o755) + sync_dir(control_directory) + write_json(control_directory / "control.json", {"schema_version": 1, "token": token, + "mode": mode, "manifest": manifest}, mode=0o644) + + def hold(self, record, token, deadline, manifest=None): + self.control(record, "hold", token, manifest) + if manifest is not None: + self.start(record, deadline) + while True: + try: + state = parse(http(self.url(record) + "/healthz", deadline)) + if state.get("ok") and state.get("hold_token") == token: + require(state.get("runtime_sha") == record["runtime_source_sha"], "runtime_identity") + require(matches(HEX, state.get("archive_sha256")) and matches(HEX, state.get("corpus_id")), "corpus_identity") + if manifest is not None: + require(state["archive_sha256"] == manifest["archive"]["sha256"] + and state["corpus_id"] == manifest["corpus_id"], "selection_identity") + return {**record, "corpus_id": state["corpus_id"], "archive_sha256": state["archive_sha256"], + "corpus_source_sha": state["corpus_source_sha"], "hold_token": token} + except ReleaseError as error: + if error.code not in {"http_failed", "http_status"}: + raise + time.sleep(min(.1, remaining(deadline))) + + @staticmethod + def url(record): + return f"http://127.0.0.1:{record['port']}" + + def check(self, record, deadline, *, public=False): + self.inspect(record, deadline) + state = smoke(self.policy["public_url"] if public else self.url(record), record, deadline) + if public: + raw = http(self.policy["public_url"] + "/indexes/v1/" + record["archive_sha256"] + "/snapshot.tar.gz", + deadline, limit=MAX_ARCHIVE_BYTES) + require(digest(raw) == record["archive_sha256"], "static_hash") + return state + + def switch(self, record, deadline): + path = Path(self.policy["nginx_include"]) + previous = read_file(path) + atomic(path, (f"server 127.0.0.1:{record['port']} max_conns=8;\n").encode()) + try: + run(["nginx", "-t"], deadline) + except BaseException: + atomic(path, previous) + raise + run(["nginx", "-s", "reload"], deadline) + + def stop(self, record, deadline): + if self.inspect(record, deadline) is not None: + # No automatic restart policy; deterministic named objects survive + # controller death and are reconciled, not replaced by unrelated IDs. + run(["docker", "stop", "--time", str(max(0, min(DRAIN, int(remaining(deadline)) - 5))), record["name"]], deadline) + info = self.inspect(record, deadline) + require(not info["State"]["Running"], "stop_failed") + + def resume(self, record, deadline): + token = digest((record["release_id"] + ":resume").encode())[:32] + self.control(record, "resume", token) + while True: + health = parse(http(self.url(record) + "/healthz", deadline)) + require(health.get("runtime_sha") == record["runtime_source_sha"] and health.get("ok"), "resume_identity") + if health.get("hold_token") is None and health.get("release_control_token") == token: + return + time.sleep(min(.1, remaining(deadline))) + + def manifest(self, record): + raw = read_file(self.root / "manifests" / (record["archive_sha256"] + ".json")) + manifest = validate_manifest(raw) + require(manifest["corpus_id"] == record["corpus_id"] + and manifest["archive"]["sha256"] == record["archive_sha256"], "manifest_identity") + return manifest + + def bootstrap_window(self, envelope): + require(BOOTSTRAP_WINDOW.exists(), "bootstrap_window_required") + trusted_path(BOOTSTRAP_WINDOW) + return validate_bootstrap_window(parse(read_file(BOOTSTRAP_WINDOW)), envelope) + + def bootstrap_backup(self, window, deadline, *, current=False): + saved = backup_inventory(self.root, window, deadline) + require(file_hash(LEGACY_PYTHON, deadline) == saved["interpreter_sha256"], "legacy_interpreter_changed") + if current: + self.legacy_files(saved, deadline, restore=False) + require(file_hash(LEGACY_CONFIG, deadline) == saved["unit"]["sha256"] + and file_hash(Path(self.policy["nginx_include"]), deadline) == saved["upstream"]["sha256"], "legacy_config_changed") + # The only installed drop-in is our separately reviewed boot fence. + unit = dict(line.split("=", 1) for line in run(["systemctl", "show", LEGACY_UNIT, + "--property=MainPID", "--property=ActiveState", "--property=DropInPaths", + "--property=EnvironmentFiles"], deadline).decode().splitlines()) + require(unit.get("ActiveState") == "active" and unit.get("MainPID", "0").isdigit() + and int(unit["MainPID"]) > 0 and not unit.get("EnvironmentFiles") + and unit.get("DropInPaths") == "/etc/systemd/system/v8std-mcp.service.d/10-release-guard.conf", "legacy_unit_changed") + return saved + + def bootstrap_capacity(self, window, envelope, deadline, *, after_stop=False): + limits = window["capacity"] + require(limits["available_memory_bytes"] >= self.config(envelope)["memory_bytes"] + 128 * 1024 * 1024, + "capacity_reserve") + reclaim = 0 + if window["mode"] == "stop-start" and not after_stop: + value = run(["systemctl", "show", LEGACY_UNIT, "--property=MemoryCurrent", "--value"], deadline).strip() + require(value.isdigit(), "legacy_memory") + reclaim = int(value) + HostAdapter(self.root, self.policy | {"capacity": limits}).capacity(deadline, reclaim_bytes=reclaim) + + def bootstrap_prepared(self, candidate, deadline): + # Artifacts must already exist. No pull/build during the migration window. + info = json.loads(run(["docker", "image", "inspect", IMAGE + "@" + candidate["platform_digest"]], deadline))[0] + require(info["Id"] in candidate["descriptors"] and info["Os"] + "/" + info["Architecture"] == self.policy["platform"] + and info["Config"].get("Labels", {}).get("org.opencontainers.image.revision") == candidate["runtime_source_sha"], + "prepared_image") + manifest = self.manifest(candidate) + verify_archive(read_file(Path(self.policy["static_root"]) / candidate["archive_sha256"] / "snapshot.tar.gz", + MAX_ARCHIVE_BYTES), manifest) + + def arm_bootstrap_guard(self, deadline): + for path, expected in ( + ("/etc/systemd/system/v8std-mcp.service.d/10-release-guard.conf", LEGACY_GUARD), + ("/etc/systemd/system/v8std-bootstrap-recover.service", BOOTSTRAP_SERVICE), + ("/etc/systemd/system/v8std-bootstrap-recover.timer", BOOTSTRAP_TIMER)): + trusted_path(Path(path)) + require(read_file(Path(path)) == expected.encode(), "bootstrap_guard_config") + require(run(["systemctl", "is-enabled", "v8std-bootstrap-recover.timer"], deadline).strip() == b"enabled", "bootstrap_guard") + require(run(["systemctl", "is-active", "v8std-bootstrap-recover.timer"], deadline).strip() == b"active", "bootstrap_guard") + for unit in (LEGACY_UNIT, "v8std-bootstrap-recover.service", "v8std-bootstrap-recover.timer"): + properties = dict(line.split("=", 1) for line in run(["systemctl", "show", unit, + "--property=NeedDaemonReload", "--property=LoadState", "--property=DropInPaths"], deadline).decode().splitlines()) + require(properties.get("NeedDaemonReload") == "no" and properties.get("LoadState") == "loaded" + and properties.get("DropInPaths") == ( + "/etc/systemd/system/v8std-mcp.service.d/10-release-guard.conf" if unit == LEGACY_UNIT else ""), + "bootstrap_guard_loaded") + + def legacy_stop(self, deadline): + run(["systemctl", "stop", LEGACY_UNIT], deadline) + require(run(["systemctl", "show", LEGACY_UNIT, "--property=MainPID", "--value"], deadline).strip() == b"0", "legacy_stop") + + def legacy_files(self, saved, deadline, *, restore): + for component, target_root in (("app", LEGACY_APP), ("cache", LEGACY_DATA)): + require(not target_root.is_symlink(), "legacy_path") + if component == "app" and target_root.exists(): + actual = set() + for directory, dirs, files in os.walk(target_root, followlinks=False): + if RESTORE_STAGE in dirs: + info = (Path(directory) / RESTORE_STAGE).lstat() + require(stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid() + and info.st_mode & 0o777 == 0o700, "restore_staging") + dirs.remove(RESTORE_STAGE) + dirs[:] = [d for d in dirs if d != "__pycache__"] + actual.update(str((Path(directory) / name).relative_to(target_root)) for name in + files + [d for d in dirs if (Path(directory) / d).is_symlink()]) + require(actual <= set(saved["app"]), "legacy_unlisted") + for relative, metadata in sorted(saved["directories"][component].items(), key=lambda item: len(item[0])): + directory = target_root / relative + if restore: + ensure_directory(directory) + os.chown(directory, metadata["uid"], metadata["gid"]) + os.chmod(directory, metadata["mode"]) + sync_dir(directory) + else: + info = directory.lstat() + require(stat.S_ISDIR(info.st_mode) and (info.st_mode & 0o777) == metadata["mode"] + and info.st_uid == metadata["uid"] and info.st_gid == metadata["gid"], "legacy_directory_changed") + for relative, entry in sorted(saved[component].items()): + target = target_root / relative + # Reject symlink ancestors before any destination write. + for parent in target.parents: + require(not parent.is_symlink(), "legacy_path") + if parent == target_root: + break + if "link" in entry: + if restore and not target.exists() and not target.is_symlink(): + ensure_directory(target.parent) + target.symlink_to(entry["link"]) + sync_dir(target.parent) + require(target.is_symlink() and os.readlink(target) == entry["link"], "legacy_link_changed") + elif restore: + restore_file(self.root / "legacy" / component / relative, target, entry, deadline) + else: + if "__pycache__" in target.parts: + continue # Derived bytecode changes on an exact-source restart. + require(file_hash(target, deadline) == entry["sha256"], "legacy_files_changed") + + def legacy_restore(self, window, deadline): + saved = self.bootstrap_backup(window, deadline) + self.legacy_files(saved, deadline, restore=True) + restore_file(self.root / "legacy/unit", LEGACY_CONFIG, saved["unit"], deadline) + restore_file(self.root / "legacy/upstream", Path(self.policy["nginx_include"]), saved["upstream"], deadline) + run(["systemctl", "daemon-reload"], deadline) + run(["nginx", "-t"], deadline) + run(["nginx", "-s", "reload"], deadline) + + def legacy_start(self, deadline): + run(["systemctl", "start", LEGACY_UNIT], deadline) + + def legacy_identity(self, deadline): + pid = run(["systemctl", "show", LEGACY_UNIT, "--property=MainPID", "--value"], deadline).strip() + require(pid.isdigit() and int(pid) > 0, "legacy_process") + arguments = read_file(Path("/proc") / pid.decode() / "cmdline").split(b"\0")[:-1] + expected = [str(LEGACY_APP / "venv/bin/python"), str(LEGACY_APP / "scripts/v8std_mcp_server.py"), + "--index-url", "https://v8std.ru/ai/pages.jsonl", "--vectors-url", "https://v8std.ru/ai/search-vectors.jsonl", + "--cache-dir", str(LEGACY_DATA), "--host", "127.0.0.1", "--port", "8765", "--mcp-path", "/mcp", + "--max-snippet-chars", "4000", "--usage-log", str(LEGACY_DATA / "tool-usage.jsonl")] + require(arguments == [arg.encode() for arg in expected] + and Path(os.readlink(Path("/proc") / pid.decode() / "exe")) == LEGACY_PYTHON, "legacy_process") + + def legacy_check(self, window, deadline, *, public=False): + saved = self.bootstrap_backup(window, deadline) + self.legacy_files(saved, deadline, restore=False) + url = self.policy["public_url"] if public else "http://127.0.0.1:8765" + while True: + try: + self.legacy_identity(deadline) + health = parse(http(url + "/healthz", deadline)) + require(health.get("ok") is True and health.get("sha256") == saved["cache"]["pages.jsonl"]["sha256"] + and health.get("vectors", {}).get("sha256") == saved["cache"]["search-vectors.jsonl"]["sha256"], "legacy_health_identity") + rpc(url, "initialize", {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "v8std-release", "version": "1"}}, deadline, 1) + result = rpc(url, "tools/call", {"name": "v8std_search", "arguments": {"query": "std437", "limit": 1}}, deadline, 2) + require(bool(result.get("content") or result.get("structuredContent")), "legacy_search") + for number, (uri, name) in enumerate((("v8std://llms.txt", "llms.txt"), + ("v8std://llms-full.txt", "llms-full.txt"), ("v8std://ai/pages.jsonl", "pages.jsonl")), 3): + contents = rpc(url, "resources/read", {"uri": uri}, deadline, number, limit=32 * 1024 * 1024).get("contents", []) + require(len(contents) == 1 and isinstance(contents[0].get("text"), str) + and digest(contents[0]["text"].encode()) == saved["cache"][name]["sha256"], "legacy_resource") + after = parse(http(url + "/healthz", deadline)) + require(after.get("sha256") == health["sha256"] and after.get("vectors", {}).get("sha256") + == health["vectors"]["sha256"], "legacy_generation_changed") + return health + except ReleaseError as error: + if error.code not in {"http_failed", "legacy_process"}: + raise + time.sleep(min(.1, remaining(deadline))) + + +class Controller: + def __init__(self, root, adapter): + self.root, self.adapter = Path(root), adapter + + def journals(self): + directory = self.root / "releases" + return [read_record(path) for path in directory.glob("*.json")] if directory.exists() else [] + + def status(self): + records = self.journals() + if not records: + return {"state": "EMPTY"} + journal = max(records, key=lambda item: item["envelope"]["sequence"]) + return self.result(journal) + + @staticmethod + def result(journal): + if journal["state"] == "REJECTED": + return dict(journal) # Durable queued rejection is already a public outcome. + return {**journal["envelope"], "state": journal["state"], "intent": journal["intent"], + "error_code": journal.get("error_code"), "cleanup_complete": bool( + journal.get("cleanup_complete", False) and not journal.get("active_recovery"))} + + def save(self, journal, state=None, intent=None): + if state: + journal["state"] = state + if intent: + journal["intent"] = intent + journal["updated_at"] = time.time() + write_json(self.root / "releases" / (journal["envelope"]["release_id"] + ".json"), journal) + + def existing(self, envelope): + rejected_path = self.root / "rejected" / (envelope["release_id"] + ".json") + if rejected_path.exists(): + rejected = read_record(rejected_path) + require({key: rejected.get(key) for key in envelope} == envelope, "mutated_duplicate") + return rejected + records = self.journals() + for item in records: + if item["envelope"]["release_id"] == envelope["release_id"]: + require(item["envelope"] == envelope, "mutated_duplicate") + return item + require(not records or envelope["sequence"] > max(x["envelope"]["sequence"] for x in records), "stale_sequence") + require(not any(x.get("active_recovery") or x["state"] not in TERMINAL or x["state"] == "RECOVERY_REQUIRED" + or x["state"] == "COMMITTED" and not x.get("cleanup_complete") for x in records), "recovery_pending") + return None + + def deploy(self, raw): + envelope = validate_envelope(raw, expired=True) + with locked(self.root): + existing = self.existing(envelope) + if existing: + return self.result(existing) + require(self.adapter.policy.get("runtime_enabled") is True, "runtime_not_activated") + self.adapter.config(envelope) + validate_envelope(raw) + require(envelope["deadline"] - time.time() > RECOVERY_RESERVE, "insufficient_transaction_budget") + require((self.root / "active.json").is_file(), "predecessor_required") + previous = parse(read_file(self.root / "active.json"), 65536) + self.adapter.config(previous) + deadline = time.monotonic() + min(TRANSACTION, envelope["deadline"] - time.time()) + work = deadline - RECOVERY_RESERVE + journal = {"envelope": envelope, "state": "RECEIVED", "intent": "verify", "predecessor": previous, + "candidate": None, "cleanup_complete": False} + self.save(journal) + try: + descriptors = self.adapter.verify(envelope, work) + self.adapter.capacity(work) + manifest = self.adapter.manifest(envelope) + self.save(journal, "VERIFIED", "hold_predecessor") + token = digest(canonical_json(envelope))[:32] + previous = self.adapter.hold(previous, token, min(work, time.monotonic() + READINESS)) + journal["predecessor"] = previous + self.adapter.manifest(previous) # Published record of observed, not disk-pointer, corpus. + self.save(journal, intent="pin_predecessor") + self.pins(journal) + candidate = {**envelope, "name": "v8std-release-" + envelope["release_id"], + "envelope_hash": digest(canonical_json(envelope)), "descriptors": descriptors, + "port": next(p for p in self.adapter.policy["ports"] if p != previous["port"]), + "hold_token": token} + journal["candidate"] = candidate + self.save(journal, intent="pull_candidate") + self.adapter.pull(candidate, work) + self.save(journal, intent="start_candidate") + candidate = self.adapter.hold(candidate, token, min(work, time.monotonic() + READINESS), manifest) + journal["candidate"] = candidate + self.save(journal, "PREPARED", "candidate_smoke") + self.adapter.check(candidate, min(work, time.monotonic() + SMOKE)) + journal["switch_attempted"] = True + self.save(journal, "READY", "switch") + self.adapter.switch(candidate, work) + self.save(journal, "SWITCHED", "public_smoke") + self.adapter.check(candidate, min(work, time.monotonic() + SMOKE), public=True) + self.save(journal, "COMMITTED", "accept_pointer") + self.cleanup(journal, deadline) + except Exception as error: + journal["error_code"] = error.code if isinstance(error, ReleaseError) else "host_failure" + self.save(journal) + if journal["state"] == "RECEIVED": + # Rejected authority cannot trigger runtime mutation. + journal["cleanup_complete"] = True + self.save(journal, "FAILED", "complete") + elif journal["state"] == "COMMITTED": + # Accepted release is never undone by post-commit cleanup failure. + self.save(journal, intent="cleanup_pending") + else: + self.rollback(journal, deadline) + return self.status() + + + def pins(self, journal): + # Pins retain public objects independent of the mutable manifest pointer. + retained = {journal["envelope"]["archive_sha256"], journal["predecessor"]["archive_sha256"]} + previous = self.root / "predecessor.json" + if previous.exists(): + retained.add(parse(read_file(previous), 65536)["archive_sha256"]) + write_json(self.root / "pins.json", {"archives": sorted(retained)}) + + def cleanup(self, journal, deadline): + candidate = journal["candidate"] + self.save(journal, intent="ensure_accepted_candidate") + candidate = self.adapter.hold(candidate, candidate["hold_token"], + min(deadline - STOP - SMOKE - 5, time.monotonic() + READINESS), self.adapter.manifest(candidate)) + journal["candidate"] = candidate + self.adapter.switch(candidate, deadline - STOP - SMOKE) + self.adapter.check(candidate, min(deadline - STOP, time.monotonic() + SMOKE), public=True) + self.save(journal, intent="accept_pointer") + write_json(self.root / "active.json", candidate) + write_json(self.root / "predecessor.json", journal["predecessor"]) + self.pins(journal) + self.save(journal, intent="drain_predecessor") + self.adapter.stop(journal["predecessor"], min(deadline, time.monotonic() + STOP)) + self.save(journal, intent="resume_candidate") + self.adapter.resume(candidate, min(deadline, time.monotonic() + SMOKE)) + journal["cleanup_complete"] = True + journal.pop("error_code", None) + self.save(journal, intent="complete") + + def rollback(self, journal, deadline): + switched = journal.get("switch_attempted", False) + try: + previous = journal["predecessor"] + self.save(journal, intent="restore_predecessor") + # A crash during capture might leave no acknowledged identity. Select + # the persisted previously accepted generation, never a newer cache pointer. + token = previous.get("hold_token") or digest((previous["release_id"] + ":recover").encode())[:32] + previous = self.adapter.hold(previous, token, min(deadline - STOP - SMOKE - 5, + time.monotonic() + READINESS), self.adapter.manifest(previous)) + journal["predecessor"] = previous + self.save(journal, intent="restore_upstream") + self.adapter.switch(previous, deadline - STOP - SMOKE) + self.adapter.check(previous, min(deadline - STOP, time.monotonic() + SMOKE), public=True) + write_json(self.root / "active.json", previous) + if journal["candidate"]: + self.save(journal, intent="stop_candidate") + self.adapter.stop(journal["candidate"], min(deadline, time.monotonic() + STOP)) + self.adapter.resume(previous, deadline) + journal["cleanup_complete"] = True + self.save(journal, "ROLLED_BACK" if switched else "FAILED", "complete") + except Exception: + journal["error_code"] = "rollback_failed" + self.save(journal, "RECOVERY_REQUIRED", "operator_recovery") + + def recover(self): + with locked(self.root): + records = self.journals() + if not records: + return self.status() + journal = max(records, key=lambda item: item["envelope"]["sequence"]) + if journal.get("kind") == "initial-install": + return InitialInstallController(self.root, self.adapter).reconcile(journal) + if journal.get("kind") == "bootstrap": + return BootstrapController(self.root, self.adapter).reconcile(journal) + if journal.get("cleanup_complete"): + active_path = self.root / "active.json" + if active_path.exists(): + active = parse(read_file(active_path), 65536) + deadline = time.monotonic() + TRANSACTION + try: + info = self.adapter.inspect(active, deadline) + if journal.get("active_recovery") or info is None or not info["State"]["Running"]: + # This is a new recovery transaction, not the old + # release's completed drain. Persist before start; + # Running alone never discharges switch/smoke/resume. + active = journal.get("active_recovery", {}).get("record", active) + journal["active_recovery"] = {"record": active} + self.save(journal, intent="recover_active_hold") + token = digest((active["release_id"] + ":restart").encode())[:32] + active = self.adapter.hold(active, token, + min(deadline - STOP - SMOKE, time.monotonic() + READINESS), + self.adapter.manifest(active)) + journal["active_recovery"]["record"] = active + self.save(journal, intent="recover_active_switch") + self.adapter.switch(active, deadline - 2 * SMOKE) + self.save(journal, intent="recover_active_smoke") + self.adapter.check(active, min(deadline - SMOKE, time.monotonic() + SMOKE), public=True) + self.save(journal, intent="recover_active_pointer") + write_json(active_path, active) + self.save(journal, intent="recover_active_resume") + self.adapter.resume(active, min(deadline, time.monotonic() + SMOKE)) + journal.pop("active_recovery") + journal["intent"] = "complete" + journal.pop("error_code", None) + self.save(journal) + except Exception: + journal["error_code"] = "active_recovery_failed" + self.save(journal) + return self.status() + if journal["state"] == "RECEIVED": + journal["cleanup_complete"] = True + journal["error_code"] = "interrupted_verification" + self.save(journal, "FAILED", "complete") + return self.status() + # A separate detached recovery job has its own bounded 300s budget. + # This never extends the candidate's original acceptance deadline. + deadline = time.monotonic() + TRANSACTION + if journal["state"] == "COMMITTED": + try: + self.cleanup(journal, deadline) + except Exception: + journal["error_code"] = "cleanup_failed" + self.save(journal, intent="cleanup_pending") + else: + self.rollback(journal, deadline) + return self.status() + + +def validate_initial_journal(journal, policy): + """Fail closed before deriving any owned path or recovery effect.""" + require(journal.get("kind") == "initial-install" and "predecessor" not in journal, "initial_history") + envelope = validate_envelope(canonical_json(journal.get("envelope")), expired=True) + require(journal.get("state") in {"RECEIVED", "VERIFIED", "PREPARED", "READY", "SWITCHED", + "COMMITTED", "FAILED", "RECOVERY_REQUIRED"} + and type(journal.get("cleanup_complete")) is bool + and isinstance(journal.get("intent"), str) and "candidate" in journal, "initial_journal") + require(not journal["cleanup_complete"] or journal["state"] in {"FAILED", "COMMITTED"}, "initial_journal") + candidate = journal["candidate"] + if candidate is None: + require(journal["state"] in {"RECEIVED", "FAILED", "RECOVERY_REQUIRED"}, "initial_candidate") + return + hashed = digest(canonical_json(envelope)) + require(isinstance(candidate, dict) and all(candidate.get(k) == v for k, v in envelope.items()) + and candidate.get("name") == "v8std-release-" + envelope["release_id"] + and candidate.get("envelope_hash") == hashed and candidate.get("hold_token") == hashed[:32] + and candidate.get("port") == policy["ports"][0], "initial_candidate") + descriptors = candidate.get("descriptors") + require(isinstance(descriptors, dict) and descriptors.get(envelope["image_digest"]) in INDEX_TYPES + and descriptors.get(envelope["platform_digest"]) in MANIFEST_TYPES + and all(matches(DIGEST, key) and value in INDEX_TYPES | MANIFEST_TYPES | CONFIG_TYPES + for key, value in descriptors.items()), "initial_descriptors") + manifest = validate_manifest(canonical_json(journal.get("manifest"))) + require(manifest["corpus_id"] == envelope["corpus_id"] + and manifest["archive"]["sha256"] == envelope["archive_sha256"], "manifest_identity") + + +class InitialInstallController(Controller): + """One durable first acceptance, with maintenance as the precommit outcome.""" + + def admission(self, envelope, journals, deadline): + require(self.adapter.policy.get("enabled") is True and self.adapter.policy.get("runtime_enabled") is False, + "initial_policy") + self.adapter.initial_authorization(envelope) + self.adapter.config(envelope) + self.adapter.initial_host(journals, deadline) + + def submit(self, raw: bytes) -> dict: + envelope = validate_envelope(raw, expired=True) + with locked(self.root): + existing = self.existing(envelope) + if existing: + return self.result(existing) + validate_envelope(raw) + require(envelope["deadline"] - time.time() > INITIAL_RECOVERY_RESERVE, "insufficient_transaction_budget") + deadline = time.monotonic() + min(TRANSACTION, envelope["deadline"] - time.time()) + self.admission(envelope, self.journals(), deadline - INITIAL_RECOVERY_RESERVE) + journal = {"kind": "initial-install", "envelope": envelope, "state": "RECEIVED", "intent": "verify", + "candidate": None, "cleanup_complete": False} + self.save(journal) + # A failed enqueue preserves the receipt. Recovery never retries a + # RECEIVED attempt; the operator must authorize a fresh ID after cleanup. + schedule("initial-install") + return self.result(journal) + + def execute(self) -> dict: + with locked(self.root): + records = self.journals() + require(bool(records), "initial_missing") + journal = max(records, key=lambda item: item["envelope"]["sequence"]) + validate_initial_journal(journal, self.adapter.policy) + if journal["state"] != "RECEIVED": + return self.result(journal) + envelope = journal["envelope"] + deadline = time.monotonic() + min(TRANSACTION, envelope["deadline"] - time.time()) + work = deadline - INITIAL_RECOVERY_RESERVE + try: + validate_envelope(canonical_json(envelope)) + remaining(work) + self.admission(envelope, [item for item in records if item is not journal], work) + descriptors = self.adapter.verify(envelope, work) + hashed = digest(canonical_json(envelope)) + candidate = {**envelope, "name": "v8std-release-" + envelope["release_id"], + "envelope_hash": hashed, "descriptors": descriptors, + "port": self.adapter.policy["ports"][0], "hold_token": hashed[:32]} + manifest = self.adapter.manifest(candidate) + self.adapter.bootstrap_prepared(candidate, work) + self.adapter.initial_capacity(candidate, work) + journal.update(candidate=candidate, manifest=manifest) + self.save(journal, "VERIFIED", "pin_candidate") + self.pins(journal) + self.save(journal, intent="start_candidate") + candidate = self.adapter.hold(candidate, candidate["hold_token"], + min(work, time.monotonic() + READINESS), manifest) + journal["candidate"] = candidate + self.save(journal, "PREPARED", "candidate_smoke") + self.adapter.check(candidate, min(work, time.monotonic() + SMOKE)) + self.save(journal, "READY", "switch") + self.adapter.switch(candidate, work) + self.save(journal, "SWITCHED", "public_smoke") + self.adapter.check(candidate, min(work, time.monotonic() + SMOKE), public=True) + remaining(work) + self.save(journal, "COMMITTED", "accept_pointer") + self.finish(journal, deadline) + except Exception as error: + # save() mutates memory before rename/fsync; only the durable + # record decides whether acceptance has already happened. + journal = read_record(self.root / "releases" / (envelope["release_id"] + ".json")) + validate_initial_journal(journal, self.adapter.policy) + journal["error_code"] = getattr(error, "code", "host_failure") + if journal["state"] == "COMMITTED": + journal["cleanup_complete"] = False + self.save(journal, intent="cleanup_pending") + else: + self.fail(journal, deadline) + return self.result(journal) + + def pins(self, journal): + write_json(self.root / "pins.json", {"archives": [journal["envelope"]["archive_sha256"]]}) + + def fail(self, journal, deadline): + journal["cleanup_complete"] = False + completed = True + # Maintenance has at most15s, preserving up to45s for owned stop. One + # failed obligation must not suppress the other while time remains. + for intent in ("maintenance", "stop_candidate"): + try: + self.save(journal, intent=intent) + if intent == "maintenance": + self.adapter.maintenance(min(deadline, time.monotonic() + INITIAL_RECOVERY_RESERVE - STOP)) + elif journal["candidate"] is not None: + self.adapter.stop(journal["candidate"], min(deadline, time.monotonic() + STOP)) + except Exception: + completed = False + journal["cleanup_complete"] = completed + if completed: + self.save(journal, "FAILED", "complete") + else: + journal["error_code"] = "initial_cleanup_failed" + self.save(journal, "RECOVERY_REQUIRED", "operator_recovery") + + def finish(self, journal, deadline): + journal["cleanup_complete"] = False + self.save(journal, intent="ensure_accepted_candidate") + candidate = journal["candidate"] + candidate = self.adapter.hold(candidate, candidate["hold_token"], + min(deadline, time.monotonic() + READINESS), journal["manifest"]) + journal["candidate"] = candidate + self.save(journal, intent="accepted_switch") + self.adapter.switch(candidate, deadline) + self.save(journal, intent="accepted_smoke") + self.adapter.check(candidate, min(deadline, time.monotonic() + SMOKE), public=True) + self.save(journal, intent="accept_pointer") + write_json(self.root / "active.json", candidate) + self.pins(journal) + self.save(journal, intent="resume_candidate") + self.adapter.resume(candidate, min(deadline, time.monotonic() + SMOKE)) + journal["cleanup_complete"] = True + journal.pop("error_code", None) + self.save(journal, intent="complete") + + def reconcile(self, journal: dict) -> dict: + # Caller holds the common release lock and selects the newest journal, + # so ordinary releases always supersede this historical first image. + validate_initial_journal(journal, self.adapter.policy) + deadline = time.monotonic() + TRANSACTION + if journal["state"] == "COMMITTED": + try: + info = self.adapter.inspect(journal["candidate"], deadline) + path = self.root / "active.json" + pointer = read_record(path) if path.exists() else None + if not journal["cleanup_complete"] or info is None or not info["State"]["Running"] or pointer != journal["candidate"]: + self.finish(journal, deadline) + except Exception: + journal.update(cleanup_complete=False, error_code="initial_recovery_failed") + self.save(journal, intent="cleanup_pending") + elif not journal["cleanup_complete"]: + if journal["state"] == "RECEIVED": + journal["error_code"] = "interrupted_preparation" + self.fail(journal, deadline) + return self.result(journal) + + +class BootstrapController(Controller): + """Initial acceptance only. It never activates policy or invents a predecessor.""" + + def submit(self, raw): + envelope = validate_envelope(raw, expired=True) + with locked(self.root): + existing = self.existing(envelope) + if existing: + return self.result(existing) + require(not (self.root / "active.json").exists(), "bootstrap_already_accepted") + require(not any(x["state"] == "COMMITTED" for x in self.journals()), "bootstrap_already_accepted") + validate_envelope(raw) + require(envelope["deadline"] - time.time() > RECOVERY_RESERVE, "insufficient_transaction_budget") + self.adapter.config(envelope) + window = self.adapter.bootstrap_window(envelope) + validate_bootstrap_window(window, envelope) + journal = {"kind": "bootstrap", "envelope": envelope, "window": window, "state": "RECEIVED", + "intent": "queued", "candidate": None, "cleanup_complete": False, "legacy_start_allowed": True} + self.save(journal) + schedule("bootstrap") + return self.result(journal) + + def execute(self): + with locked(self.root): + records = self.journals() + require(bool(records), "bootstrap_missing") + journal = max(records, key=lambda item: item["envelope"]["sequence"]) + require(journal.get("kind") == "bootstrap", "bootstrap_missing") + if journal["state"] != "RECEIVED": + return self.result(journal) + envelope, window = journal["envelope"], journal["window"] + deadline = time.monotonic() + min(TRANSACTION, envelope["deadline"] - time.time()) + work = deadline - RECOVERY_RESERVE + try: + validate_bootstrap_window(window, envelope) + validate_envelope(canonical_json(envelope)) + remaining(work) + require(not (self.root / "active.json").exists(), "bootstrap_already_accepted") + descriptors = self.adapter.verify(envelope, work) + self.adapter.bootstrap_backup(window, work, current=True) + self.adapter.bootstrap_capacity(window, envelope, work) + token = digest(canonical_json(envelope))[:32] + candidate = {**envelope, "name": "v8std-release-" + envelope["release_id"], + "envelope_hash": digest(canonical_json(envelope)), "descriptors": descriptors, + "port": self.adapter.policy["ports"][0], "hold_token": token} + self.adapter.bootstrap_prepared(candidate, work) + self.adapter.legacy_check(window, min(work, time.monotonic() + SMOKE)) + self.adapter.arm_bootstrap_guard(work) + validate_bootstrap_window(window, envelope) + journal.update(candidate=candidate, legacy_start_allowed=False) + self.save(journal, "VERIFIED", "guard_armed") + write_json(self.root / "pins.json", {"archives": [envelope["archive_sha256"]]}) + if window["mode"] == "stop-start": + self.save(journal, intent="stop_legacy") + self.adapter.legacy_stop(min(work, time.monotonic() + STOP)) + self.adapter.bootstrap_capacity(window, envelope, work, after_stop=True) + self.save(journal, intent="start_candidate") + candidate = self.adapter.hold(candidate, token, min(work, time.monotonic() + READINESS), + self.adapter.manifest(candidate)) + journal["candidate"] = candidate + self.save(journal, "PREPARED", "candidate_smoke") + self.adapter.check(candidate, min(work, time.monotonic() + SMOKE)) + self.save(journal, "READY", "switch") + self.adapter.switch(candidate, work) + self.save(journal, "SWITCHED", "public_smoke") + self.adapter.check(candidate, min(work, time.monotonic() + SMOKE), public=True) + remaining(work) + # Durable COMMITTED is the acceptance point, never active.json. + self.save(journal, "COMMITTED", "accept_pointer") + self.finish(journal, deadline) + except Exception as error: + # A failed fsync/rename has an uncertain result: re-read the + # durable journal instead of trusting a mutated in-memory state. + journal = read_record(self.root / "releases" / (envelope["release_id"] + ".json")) + journal["error_code"] = getattr(error, "code", "host_failure") + if journal["state"] == "COMMITTED": + self.save(journal, intent="cleanup_pending") + elif journal["state"] == "RECEIVED": + journal["cleanup_complete"] = True + self.save(journal, "FAILED", "complete") + else: + self.rollback(journal, deadline) + return self.result(journal) + + def finish(self, journal, deadline): + # Also used after accepted crash/reboot. Fence before any candidate start. + journal["cleanup_complete"] = False + journal["legacy_start_allowed"] = False + self.save(journal, intent="ensure_accepted_candidate") + self.adapter.legacy_stop(min(deadline - READINESS - 2 * SMOKE, time.monotonic() + STOP)) + candidate = journal["candidate"] + candidate = self.adapter.hold(candidate, candidate["hold_token"], + min(deadline - 2 * SMOKE, time.monotonic() + READINESS), self.adapter.manifest(candidate)) + journal["candidate"] = candidate + self.save(journal, intent="accepted_switch") + self.adapter.switch(candidate, deadline - 2 * SMOKE) + self.save(journal, intent="accepted_smoke") + self.adapter.check(candidate, min(deadline - SMOKE, time.monotonic() + SMOKE), public=True) + self.save(journal, intent="accept_pointer") + write_json(self.root / "active.json", candidate) + write_json(self.root / "pins.json", {"archives": [candidate["archive_sha256"]]}) + self.save(journal, intent="resume_candidate") + self.adapter.resume(candidate, min(deadline, time.monotonic() + SMOKE)) + journal["cleanup_complete"] = True + journal.pop("error_code", None) + self.save(journal, intent="complete") + + def rollback(self, journal, deadline): + try: + journal["legacy_start_allowed"] = False + self.save(journal, intent="stop_candidate") + if journal["candidate"]: + self.adapter.stop(journal["candidate"], min(deadline - READINESS - SMOKE - 5, time.monotonic() + STOP)) + # Stop legacy too if overlap was chosen; restore coherent bytes only + # while the original process is stopped, then restart its exact unit. + self.save(journal, intent="restore_legacy") + self.adapter.legacy_stop(min(deadline - READINESS - SMOKE, time.monotonic() + STOP)) + self.adapter.legacy_restore(journal["window"], deadline - READINESS - SMOKE) + journal["legacy_start_allowed"] = True + self.save(journal, intent="start_legacy") + self.adapter.legacy_start(min(deadline - SMOKE, time.monotonic() + READINESS)) + self.save(journal, intent="legacy_smoke") + self.adapter.legacy_check(journal["window"], min(deadline, time.monotonic() + SMOKE), public=True) + journal["cleanup_complete"] = True + self.save(journal, "ROLLED_BACK", "complete") + except Exception: + journal["cleanup_complete"] = False + journal["error_code"] = "legacy_recovery_failed" + self.save(journal, "RECOVERY_REQUIRED", "restore_legacy") + + def reconcile(self, journal): + # Caller holds the same global release/publication lock. No window gate: + # restoring owed work remains required after expiry or policy disablement. + if journal["state"] == "RECEIVED": + journal["cleanup_complete"] = True + self.save(journal, "FAILED", "interrupted_preparation") + elif journal["state"] == "COMMITTED": + candidate = journal["candidate"] + deadline = time.monotonic() + TRANSACTION + info = self.adapter.inspect(candidate, deadline) + if not journal.get("cleanup_complete") or info is None or not info["State"]["Running"]: + try: + self.finish(journal, deadline) + except Exception: + journal.update(cleanup_complete=False, error_code="bootstrap_cleanup_failed") + self.save(journal, intent="cleanup_pending") + elif not journal.get("cleanup_complete"): + self.rollback(journal, time.monotonic() + TRANSACTION) + return self.result(journal) + + +def validate_upload(header): + require(set(header) == {"schema_version", "publication_id", "sequence", "trigger_sha", + "manifest", "deadline", "action"}, "upload_fields") + require(type(header["schema_version"]) is int and header["schema_version"] == 1, "schema") + require(matches(ID, header["publication_id"]) and matches(SHA, header["trigger_sha"]), "upload_identity") + require(type(header["sequence"]) is int and 0 < header["sequence"] <= 2**53 - 1, "sequence") + require(type(header["deadline"]) is int and header["action"] in {"publish", "reference"}, "upload_action") + manifest = validate_manifest(canonical_json(header["manifest"])) + expected = "https://ai.v8std.ru/indexes/v1/" + manifest["archive"]["sha256"] + "/snapshot.tar.gz" + require(manifest["archive"]["path"] == expected, "upload_archive_path") + return header + + +def read_stream(fd, amount, deadline): + """Actual fd reads, including pipes; a slow uploader cannot own the lock forever.""" + output = bytearray() + while len(output) < amount: + require(select.select([fd], [], [], remaining(deadline, 20))[0], "ingress_timeout") + data = os.read(fd, min(65536, amount - len(output))) + require(bool(data), "ingress_truncated") + output.extend(data) + return bytes(output) + + +def read_header(fd, deadline, limit=65536): + output = bytearray() + while len(output) <= limit: + char = read_stream(fd, 1, deadline) + if char == b"\n": + return parse(bytes(output), limit) + output.extend(char) + raise ReleaseError("input_size") + + +def eof(fd, deadline): + require(select.select([fd], [], [], remaining(deadline, 20))[0], "ingress_timeout") + require(os.read(fd, 1) == b"", "ingress_extra_bytes") + + +def remove_upload(path): + if not path.exists(): + return + require(not path.is_symlink() and path.is_dir(), "stage_shape") + require(all(item.name == "snapshot.tar.gz" and stat.S_ISREG(item.lstat().st_mode) + for item in path.iterdir()), "stage_shape") + shutil.rmtree(path) + sync_dir(path.parent) + + +def cleanup_uploads(root, static_root): + """Called only under release.lock, so no live ingress can own these files.""" + if not static_root.exists(): + return + for path in static_root.iterdir(): + if re.fullmatch(r"\.upload-[a-z0-9_]+", path.name): + remove_upload(path) + elif path.name.startswith(".stage-") and matches(ID, path.name[7:]): + receipt = root / "publications" / (path.name[7:] + ".json") + if not receipt.exists() or read_record(receipt)["state"] in {"COMMITTED", "FAILED"}: + remove_upload(path) + + +def publication_result(record): + header = record["header"] + manifest = header["manifest"] + return {"publication_id": header["publication_id"], "sequence": header["sequence"], + "action": header["action"], "state": record["state"], "trigger_sha": header["trigger_sha"], + "corpus_source_sha": manifest["source_sha"], "corpus_id": manifest["corpus_id"], + "archive_sha256": manifest["archive"]["sha256"], "error_code": record.get("error_code"), + "cleanup_complete": record["state"] == "COMMITTED" and not record.get("cleanup_pending", False)} + + +def validate_publication_record(record, identity): + """One bounded receipt schema, shared with operator publication handoff.""" + try: + require(isinstance(record, dict) and {"header", "state"} <= set(record) + <= {"header", "state", "cleanup_pending", "error_code"}, "publication_history") + header = validate_upload(record["header"]) + require(header["publication_id"] == identity + and record["state"] in {"RECEIVED", "VERIFIED", "RECOVERY_REQUIRED", "COMMITTED", "FAILED"}, + "publication_history") + require("cleanup_pending" not in record or type(record["cleanup_pending"]) is bool, "publication_history") + require("error_code" not in record or matches(re.compile(r"[a-z][a-z0-9_]{0,127}\Z"), record["error_code"]), + "publication_history") + return record + except (ValueError, TypeError, KeyError): + raise ReleaseError("publication_history") from None + + +def publication_history(root, *, deadline=None): + """Read all receipts under release.lock; malformed history never lowers the watermark.""" + deadline = time.monotonic() + TRANSACTION if deadline is None else deadline + directory = Path(root) / "publications" + records, sequences = {}, set() + if not os.path.lexists(directory): + return records + require(stat.S_ISDIR(directory.lstat().st_mode), "publication_history") + for path in directory.iterdir(): + remaining(deadline) + info = path.lstat() + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, "publication_history") + # atomic() may die before rename. Its private, owned temporary file is + # not a receipt, including when only partially written. Never adopt it. + if re.fullmatch(r"\.write-[a-z0-9_]{8}", path.name): + require(info.st_uid == os.geteuid() and stat.S_IMODE(info.st_mode) == 0o600 + and info.st_size <= 128 * 1024, "publication_history") + continue + require(path.suffix == ".json" and matches(ID, path.stem), "publication_history") + record = validate_publication_record(read_record(path), path.stem) + sequence = record["header"]["sequence"] + require(sequence not in sequences, "publication_history") + sequences.add(sequence) + records[path.stem] = record + return records + + +def validate_handoff_receipt(record): + require(isinstance(record, dict) and set(record) == + {"schema_version", "handoff_sha256", "state", "publication_sequence"} + and type(record["schema_version"]) is int and record["schema_version"] == 1 + and matches(HEX, record["handoff_sha256"]) and record["state"] in {"PREPARED", "COMMITTED"} + and type(record["publication_sequence"]) is int + and 0 <= record["publication_sequence"] <= 2**53 - 1, "handoff_receipt") + return record + + +def publication_admission(root, records): + marker = Path(root) / "handoff-import.json" + if os.path.lexists(marker): + record = validate_handoff_receipt(read_record(marker)) + require(record["state"] == "COMMITTED", "handoff_incomplete") + sequences = {r["header"]["sequence"] for r in records.values()} + require(record["publication_sequence"] in sequences, "handoff_history_missing") + return max((r["header"]["sequence"] for r in records.values()), default=0) + + +def restore_index_inbox(root): + """Under release.lock: the fsynced receipt also is an enqueue intent.""" + pending = root / "pending-index.json" + if pending.exists(): + return + directory = root / "publications" + records = [read_record(path) for path in directory.glob("*.json")] + unfinished = [r for r in records if r["state"] not in {"COMMITTED", "FAILED"} or r.get("cleanup_pending")] + if unfinished: + record = min(unfinished, key=lambda r: (r["header"]["sequence"], r["header"]["publication_id"])) + write_json(pending, record["header"]) + + +def query_status(root, adapter, query=None): + if query is None: + return Controller(root, adapter).status() + require(set(query) == {"schema_version", "kind", "id"} + and type(query["schema_version"]) is int and query["schema_version"] == 1 + and query["kind"] in {"release", "publication"} and matches(ID, query["id"]), "status_query") + directory = "releases" if query["kind"] == "release" else "publications" + path = Path(root) / directory / (query["id"] + ".json") + if path.exists(): + record = read_record(path) + return Controller.result(record) if query["kind"] == "release" else publication_result(record) + if query["kind"] == "release": + pending = Path(root) / "pending-deploy.json" + if pending.exists(): + envelope = parse(read_file(pending)) + if envelope["release_id"] == query["id"]: + return {**envelope, "state": "QUEUED", "cleanup_complete": False} + rejected = Path(root) / "rejected" / (query["id"] + ".json") + if rejected.exists(): + return parse(read_file(rejected)) + return {"state": "NOT_FOUND", "id": query["id"], "kind": query["kind"]} + + +def read_status_query(fd): + deadline = time.monotonic() + 20 + require(select.select([fd], [], [], remaining(deadline))[0], "ingress_timeout") + first = os.read(fd, 1) + if first == b"": + return None + raw = bytearray(first) + while not raw.endswith(b"\n") and len(raw) <= 1024: + raw.extend(read_stream(fd, 1, deadline)) + eof(fd, deadline) + return parse(bytes(raw), 1024) + + +def ingest(root, static_root, fd, *, seconds=30): + """JSON line + exactly archive.bytes raw bytes + EOF; no client-side paths.""" + root, static_root = Path(root), Path(static_root) + deadline = time.monotonic() + seconds + header = validate_upload(read_header(fd, deadline)) + with locked(root): + pending = root / "pending-index.json" + record_path = root / "publications" / (header["publication_id"] + ".json") + records = publication_history(root, deadline=deadline) + record = records.get(header["publication_id"]) + if record: + require(record["header"] == header, "mutated_duplicate") + else: + require(header["sequence"] > publication_admission(root, records), "stale_sequence") + cleanup_uploads(root, static_root) + restore_index_inbox(root) + if not record: + if pending.exists(): + require(read_record(pending) == header, "publication_busy") + require(time.time() < header["deadline"] <= time.time() + TRANSACTION, "deadline") + manifest = header["manifest"] + size = manifest["archive"]["bytes"] if header["action"] == "publish" else 0 + require(0 <= size <= MAX_ARCHIVE_BYTES, "input_size") + ensure_directory(static_root, 0o755) + require(shutil.disk_usage(static_root).free > 2 * MAX_ARCHIVE_BYTES, "disk_capacity") + temporary = Path(tempfile.mkdtemp(prefix=".upload-", dir=static_root)) + try: + archive = temporary / "snapshot.tar.gz" + hashed = hashlib.sha256() + with archive.open("xb") as stream: + for start in range(0, size, 65536): + chunk = read_stream(fd, min(65536, size - start), deadline) + hashed.update(chunk) + stream.write(chunk) + stream.flush() + os.fsync(stream.fileno()) + eof(fd, deadline) + if size: + require(hashed.hexdigest() == manifest["archive"]["sha256"], "archive_hash") + if record: + return publication_result(record) + if size: + stage = static_root / (".stage-" + header["publication_id"]) + if stage.exists(): + require(read_file(stage / "snapshot.tar.gz", MAX_ARCHIVE_BYTES) == read_file(archive, MAX_ARCHIVE_BYTES), "stage_conflict") + else: + sync_dir(temporary) + os.rename(temporary, stage) + sync_dir(static_root) + write_json(record_path, {"header": header, "state": "RECEIVED"}) + write_json(pending, header) + return {"publication_id": header["publication_id"], "state": "QUEUED"} + finally: + if temporary.exists(): + shutil.rmtree(temporary) + + +class Publisher: + def __init__(self, root, static_root, verifier=None): + self.root, self.static_root = Path(root), Path(static_root) + self.verifier = verifier or self.verify + + @staticmethod + def verify(archive, header, deadline): + run(attestation_command(str(archive), header["manifest"]["source_sha"]), deadline) + for sha in {header["manifest"]["source_sha"], header["trigger_sha"]}: + comparison = parse(run(["gh", "api", f"repos/{REPO}/compare/{sha}...main"], deadline), 2 * 1024 * 1024) + require(comparison.get("status") in {"ahead", "identical"} + and comparison.get("merge_base_commit", {}).get("sha") == sha, "main_ancestry") + + def unacknowledged_archives(self, after, *, through=None): + """Caller holds release.lock; publish receipts outlive their inboxes. + + Until a later reference acknowledges Pages progress, an exposed archive + may still be the public pointer's target. Age cannot resolve that gap. + Include incomplete nonfailed receipts conservatively across recovery. + """ + archives = set() + for path in (self.root / "publications").glob("*.json"): + record = read_record(path) + header = record["header"] + if (header["action"] == "publish" and record["state"] != "FAILED" + and header["sequence"] > after + and (through is None or header["sequence"] <= through)): + archives.add(header["manifest"]["archive"]["sha256"]) + return archives + + def publish(self, header): + header = validate_upload(header) + with locked(self.root): + manifest = header["manifest"] + archive_hash = manifest["archive"]["sha256"] + record_path = self.root / "publications" / (header["publication_id"] + ".json") + records = publication_history(self.root, deadline=time.monotonic() + TRANSACTION) + require(header["publication_id"] in records, "publication_history") + record = records[header["publication_id"]] + require(record["header"] == header, "mutated_duplicate") + if record["state"] == "COMMITTED": + return self.finish_cleanup(record_path, record) + verified = record["state"] in {"VERIFIED", "RECOVERY_REQUIRED"} + require(record["state"] != "FAILED", "publication_terminal") + if not verified: + publication_admission(self.root, records) + require(header["sequence"] > max((r["header"]["sequence"] for key, r in records.items() + if key != header["publication_id"]), default=0), "stale_sequence") + deadline = time.monotonic() + (TRANSACTION if verified else min(TRANSACTION, header["deadline"] - time.time())) + remaining(deadline) + target = self.static_root / archive_hash + stage = self.static_root / (".stage-" + header["publication_id"]) + if header["action"] == "publish": + archive = (target if target.exists() else stage) / "snapshot.tar.gz" + verify_archive(read_file(archive, MAX_ARCHIVE_BYTES), manifest) + if not verified: + self.verifier(archive, header, deadline) + # Bookkeeping precedes visibility. The durable publish receipt + # also protects an unknown Pages outcome until a later reference. + reference = self.root / "references" / (archive_hash + ".json") + write_json(reference, {"last_reference": time.time()}) + write_json(self.root / "manifests" / (archive_hash + ".json"), manifest) + record["state"] = "VERIFIED" + write_json(record_path, record) + if not target.exists(): + os.chmod(archive, 0o644) + os.chmod(stage, 0o755) + sync_dir(stage) + os.rename(stage, target) + sync_dir(self.static_root) + elif stage.exists(): + shutil.rmtree(stage) # Exact validated owned staging directory only. + else: + # A reference acknowledgment follows successful Pages publication; + # it cannot introduce an object or change the verified manifest. + require(read_record(self.root / "manifests" / (archive_hash + ".json")) == manifest, "unpublished_manifest") + verify_archive(read_file(target / "snapshot.tar.gz", MAX_ARCHIVE_BYTES), manifest) + if not verified: + self.verifier(target / "snapshot.tar.gz", header, deadline) + current_path = self.root / "current-index.json" + current = None + if current_path.exists(): + current = read_record(current_path) + require(header["sequence"] > current["sequence"] or current == header, "stale_sequence") + record["state"] = "VERIFIED" + write_json(record_path, record) + displaced = self.unacknowledged_archives(current["sequence"] if current else 0, + through=header["sequence"]) + if current: + displaced.add(current["manifest"]["archive"]["sha256"]) + # Every displaced uncertain object gets a full grace period + # BEFORE advancing the acknowledgement watermark. A crash here + # leaves the old watermark protecting unfinished writes; a retry + # after pointer persistence finds all timestamps already durable. + for key in sorted(displaced | {archive_hash}): + write_json(self.root / "references" / (key + ".json"), {"last_reference": time.time()}) + write_json(current_path, header) + record["state"] = "COMMITTED" + record["cleanup_pending"] = True + record.pop("error_code", None) + write_json(record_path, record) + return self.finish_cleanup(record_path, record) + + def finish_cleanup(self, record_path, record): + self.clear_pending(record["header"]) + if record.get("cleanup_pending") or record.get("error_code"): + record["cleanup_pending"] = False + record.pop("error_code", None) + write_json(record_path, record) + return record + + def clear_pending(self, header): + pending = self.root / "pending-index.json" + if pending.exists() and read_record(pending) == header: + pending.unlink() + sync_dir(self.root) + + def recover(self): + pending = self.root / "pending-index.json" + with locked(self.root): + cleanup_uploads(self.root, self.static_root) + restore_index_inbox(self.root) + header = read_record(pending) if pending.exists() else None + if header is None: + return {"state": "EMPTY"} + try: + return self.publish(header) + except Exception as error: + if isinstance(error, ReleaseError) and error.code == "busy": + raise # Another worker owns the journal; contention is not failure. + # A failed verifier/deadline cannot permanently occupy the ingress + # slot. Keep immutable ID outcome; a new attempt uses a new ID. + with locked(self.root): + record_path = self.root / "publications" / (header["publication_id"] + ".json") + record = read_record(record_path) + if record["state"] == "COMMITTED": + # Visibility/reference already accepted. Even unlink+fsync + # failure must only retry cleanup, never rewrite acceptance. + record.update(cleanup_pending=True, error_code="publication_cleanup_failed") + write_json(record_path, record) + return record + # Visibility may precede COMMITTED after a crash: VERIFIED receipts + # are reconciled on restart, never described as a committed job. + verified = record["state"] in {"VERIFIED", "RECOVERY_REQUIRED"} + record.update(state="RECOVERY_REQUIRED" if verified else "FAILED", + error_code=getattr(error, "code", "publication_failed")) + write_json(record_path, record) + if not verified: + self.clear_pending(header) + stage = self.static_root / (".stage-" + header["publication_id"]) + remove_upload(stage) + return record + + def gc(self, *, now=None): + """Internal operator maintenance: references and pins, never mtime alone.""" + now = time.time() if now is None else now + with locked(self.root): + pins = parse(read_file(self.root / "pins.json"))["archives"] + require(isinstance(pins, list) and all(matches(HEX, x) for x in pins), "pins_invalid") + current = read_record(self.root / "current-index.json") + retained = set(pins) | {current["manifest"]["archive"]["sha256"]} + retained.update(self.unacknowledged_archives(current["sequence"])) + pending = self.root / "pending-index.json" + if pending.exists(): + retained.add(read_record(pending)["manifest"]["archive"]["sha256"]) + removed = [] + for path in self.static_root.iterdir(): + if not matches(HEX, path.name) or path.name in retained or path.is_symlink(): + continue + reference = self.root / "references" / (path.name + ".json") + last = parse(read_file(reference))["last_reference"] + require(type(last) in {int, float} and 0 <= last <= now, "reference_invalid") + if now - last >= 7 * 86400: + # Only exact known immutable-layout objects are ours to remove. + require({p.name for p in path.iterdir()} == {"snapshot.tar.gz"}, "store_layout") + require(digest(read_file(path / "snapshot.tar.gz", MAX_ARCHIVE_BYTES)) == path.name, "store_corrupt") + shutil.rmtree(path) + sync_dir(self.static_root) + removed.append(path.name) + return removed + + +def schedule(kind): + require(kind in {"deploy", "index", "recover", "bootstrap", "initial-install"}, "job_kind") + if kind == "initial-install": + require(os.geteuid() == 0, "host_privilege") + # Shared unit name and controller lock serialize all host effects. No --pipe, + # --wait or inherited SSH stdin; timer recovers a crash before enqueue. + return run(["systemd-run", "--unit=v8std-release-job", "--collect", "--no-block", + "--property=Type=exec", "--property=RuntimeMaxSec=300s", "--property=TimeoutStopSec=5s", + "--property=KillMode=control-group", "/usr/bin/python3", "-I", INSTALL, "_" + kind], + time.monotonic() + 10) + + +def submit(root, adapter, raw): + envelope = validate_envelope(raw, expired=True) + controller = Controller(root, adapter) + with locked(root): + existing = controller.existing(envelope) + if existing: + return controller.result(existing) + require(adapter.policy.get("runtime_enabled") is True, "runtime_not_activated") + adapter.config(envelope) + require((Path(root) / "active.json").is_file(), "predecessor_required") + validate_envelope(raw) + require(envelope["deadline"] - time.time() > RECOVERY_RESERVE, "insufficient_transaction_budget") + pending = Path(root) / "pending-deploy.json" + if pending.exists(): + previous = parse(read_file(pending)) + require(previous == envelope or any(item["envelope"] == previous and item.get("cleanup_complete") + for item in controller.journals()), "busy") + write_json(pending, envelope) + schedule("deploy") + return {"state": "QUEUED", "release_id": envelope["release_id"]} + + +def main(): + require(len(sys.argv) == 2, "command") + command = sys.argv[1] + require(command in {"validate-envelope", "deploy", "recover", "status", "publish-index", + "_deploy", "_index", "_recover", "bootstrap", "bootstrap-recover", "bootstrap-status", + "_bootstrap", "_legacy-allowed", "initial-install", "_initial-install", + "initial-install-recover"}, "command") + if command == "validate-envelope": + header = read_header(sys.stdin.fileno(), time.monotonic() + 20, 8192) + eof(sys.stdin.fileno(), time.monotonic() + 20) + return validate_envelope(canonical_json(header)) + require(os.geteuid() == 0, "host_privilege") + if command == "_legacy-allowed": + require(legacy_start_allowed(ROOT), "legacy_fenced") + return {"state": "LEGACY_ALLOWED"} + policy = trusted_policy() + adapter = HostAdapter(ROOT, policy) + controller = Controller(ROOT, adapter) + if command in {"status", "bootstrap-status"}: + return query_status(ROOT, adapter, read_status_query(sys.stdin.fileno())) + if command == "deploy": + envelope = read_header(sys.stdin.fileno(), time.monotonic() + 20, 8192) + eof(sys.stdin.fileno(), time.monotonic() + 20) + return submit(ROOT, adapter, canonical_json(envelope)) + if command == "bootstrap": + envelope = read_header(sys.stdin.fileno(), time.monotonic() + 20, 8192) + eof(sys.stdin.fileno(), time.monotonic() + 20) + return BootstrapController(ROOT, adapter).submit(canonical_json(envelope)) + if command == "initial-install": + envelope = read_header(sys.stdin.fileno(), time.monotonic() + 20, 8192) + eof(sys.stdin.fileno(), time.monotonic() + 20) + return InitialInstallController(ROOT, adapter).submit(canonical_json(envelope)) + if command == "_initial-install": + return InitialInstallController(ROOT, adapter).execute() + if command == "initial-install-recover": + return controller.recover() + if command == "_bootstrap": + return BootstrapController(ROOT, adapter).execute() + if command == "bootstrap-recover": + return controller.recover() + if command == "publish-index": + result = ingest(ROOT, policy["static_root"], sys.stdin.fileno()) + if result["state"] not in {"COMMITTED", "FAILED"} or ( + result["state"] == "COMMITTED" and not result.get("cleanup_complete", True)): + schedule("index") + return result + if command == "recover": + schedule("recover") + return {"state": "RECOVERY_QUEUED"} + if command in {"_recover", "_deploy"}: + controller.recover() + pending = ROOT / "pending-deploy.json" + if pending.exists(): + envelope = parse(read_file(pending)) + try: + result = controller.deploy(canonical_json(envelope)) + if result["state"] == "REJECTED": + # Reconcile a crash after the durable rejection but before + # inbox deletion, without reconsidering its immutable ID. + with locked(ROOT): + if pending.exists() and parse(read_file(pending)) == envelope: + pending.unlink() + sync_dir(ROOT) + except ReleaseError as error: + if error.code not in {"deadline", "insufficient_transaction_budget", "runtime_not_activated", "predecessor_required", "stale_sequence"}: + raise + with locked(ROOT): + write_json(ROOT / "rejected" / (envelope["release_id"] + ".json"), + {**envelope, "state": "REJECTED", "error_code": error.code}) + if parse(read_file(pending)) == envelope: + pending.unlink() + sync_dir(ROOT) + if command in {"_recover", "_index"}: + return Publisher(ROOT, policy["static_root"]).recover() + return controller.status() + + +if __name__ == "__main__": + try: + print(json.dumps(main(), sort_keys=True)) + except Exception as error: + print(json.dumps({"state": "REJECTED", "error_code": error.code if isinstance(error, ReleaseError) else "host_failure"})) + raise SystemExit(1) diff --git a/scripts/v8std_mcp_usage.logrotate b/delivery/vps/v8std_mcp_usage.logrotate similarity index 56% rename from scripts/v8std_mcp_usage.logrotate rename to delivery/vps/v8std_mcp_usage.logrotate index ceaebca..31faff8 100644 --- a/scripts/v8std_mcp_usage.logrotate +++ b/delivery/vps/v8std_mcp_usage.logrotate @@ -8,3 +8,13 @@ su v8std-mcp v8std-mcp create 0640 v8std-mcp v8std-mcp } + +/var/log/v8std-mcp/tool-usage.jsonl { + daily + rotate 365 + compress + missingok + notifempty + copytruncate + su root root +} diff --git a/deploy/README.md b/deploy/README.md deleted file mode 100644 index f252cd4..0000000 --- a/deploy/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# MCP capacity deployment - -The public MCP is a read-only, stateless request service. Coding agents send -JSON-RPC messages with `POST`; the service deliberately rejects unsolicited -`GET` SSE streams with HTTP 405. This keeps agent connections at the edge and -prevents one idle stream from consuming one Python and one nginx connection. - -There is one combined runtime and one public `/mcp` endpoint. Do not add a -`/v3/mcp` listener or a second systemd service when the Resources profile grows. - -The nginx fragments in `deploy/nginx/` reject the optional event-stream GET at -the edge before opening an upstream connection and are intended for every edge node. The -capacity example assumes four or more edge nodes, each admitting at most -40,000 client connections, so that one node can fail without reducing the -100,000-connection target below capacity. Backends remain stateless and are -scaled from measured POST RPS rather than from client connection count. - -Before activation: - -1. Set `LimitNOFILE` for nginx and the MCP service, and verify the effective - limits after restart. -2. Set `worker_processes auto`, `worker_connections 65536`, and - `worker_shutdown_timeout 30s` on each edge node. -3. Deploy the MCP application and verify `GET /mcp` with an event-stream - Accept header returns 405 while JSON-RPC POST initialize returns 200. -4. Drain the old nginx generations. A graceful drain must be bounded by the - 30-second worker shutdown timeout. -5. Run the 100,000-agent load profile before claiming capacity. The profile - must include initialize, tools/list, realistic tool bursts, reload, and one - edge-node failure. - -The current service has no trusted client identity. Do not use -`clientInfo.name`, User-Agent, or IP as authentication. Add tenant/API-key -quotas before offering guaranteed per-customer capacity; IP rate limiting is -only an overload guard because many coding agents can share one NAT address. diff --git a/deploy/nginx/emergency-2026-09-09.http.conf b/deploy/nginx/emergency-2026-09-09.http.conf deleted file mode 100644 index 92abaa3..0000000 --- a/deploy/nginx/emergency-2026-09-09.http.conf +++ /dev/null @@ -1,17 +0,0 @@ -# Emergency SSE admission policy; include in the nginx http context. -# Deploy as /etc/nginx/conf.d/v8std-mcp-emergency.conf with the incident patch. -map "$request_method:$http_accept" $v8std_emergency_sse_get { - default 0; - ~*^GET:.*text/event-stream 1; -} - -map $status $v8std_emergency_allow { - default ""; - 405 "POST, HEAD"; -} - -map $status $v8std_emergency_retry_after { - default ""; - 429 2; - 503 5; -} diff --git a/deploy/nginx/emergency-2026-09-09.patch b/deploy/nginx/emergency-2026-09-09.patch deleted file mode 100644 index 415bebf..0000000 --- a/deploy/nginx/emergency-2026-09-09.patch +++ /dev/null @@ -1,20 +0,0 @@ ---- etc/nginx/nginx.conf -+++ etc/nginx/nginx.conf -@@ -2,0 +3,2 @@ -+worker_rlimit_nofile 8192; -+worker_shutdown_timeout 30s; -@@ -8 +10 @@ -- worker_connections 768; -+ worker_connections 4096; ---- etc/nginx/sites-available/ai.v8std.ru -+++ etc/nginx/sites-available/ai.v8std.ru -@@ -3,0 +4 @@ -+ keepalive_timeout 30s; -@@ -42,0 +44,7 @@ -+ # Stateless MCP needs no unsolicited SSE GET stream. -+ if ($v8std_emergency_sse_get) { -+ return 405; -+ } -+ add_header Allow $v8std_emergency_allow always; -+ add_header Retry-After $v8std_emergency_retry_after always; -+ limit_req_status 429; diff --git a/deploy/nginx/http-v8std-mcp.conf b/deploy/nginx/http-v8std-mcp.conf deleted file mode 100644 index a687ef7..0000000 --- a/deploy/nginx/http-v8std-mcp.conf +++ /dev/null @@ -1,26 +0,0 @@ -# Include this file from the nginx `http` context. -# -# The MCP application is stateless. Keep client connections at the edge, but -# use a small reusable upstream pool so idle agents do not consume one Python -# socket each. - -worker_rlimit_nofile 131072; - -limit_req_zone $binary_remote_addr zone=v8std_mcp_requests:10m rate=100r/s; -limit_conn_zone $server_name zone=v8std_mcp_connections:10m; - -map $status $v8std_retry_after { - default ""; - 429 2; - 503 5; -} - -map "$request_method:$http_accept" $v8std_reject_event_stream_get { - default 0; - ~^GET:.*text/event-stream 1; -} - -upstream v8std_mcp_upstream { - server 127.0.0.1:8765 max_conns=2048; - keepalive 256; -} diff --git a/deploy/nginx/nginx.conf.capacity-example b/deploy/nginx/nginx.conf.capacity-example deleted file mode 100644 index a7d187d..0000000 --- a/deploy/nginx/nginx.conf.capacity-example +++ /dev/null @@ -1,18 +0,0 @@ -# Minimal events/http fragment for a capacity-sized edge node. Merge these -# directives with the host's existing nginx.conf; do not run this file as a -# complete configuration without adding the site's MIME/certificate policy. - -worker_processes auto; -worker_shutdown_timeout 30s; - -events { - worker_connections 65536; - multi_accept on; -} - -http { - keepalive_timeout 30s; - keepalive_requests 1000; - include http-v8std-mcp.conf; - include server-v8std-mcp.conf; -} diff --git a/deploy/nginx/server-v8std-mcp.conf b/deploy/nginx/server-v8std-mcp.conf deleted file mode 100644 index 3f30083..0000000 --- a/deploy/nginx/server-v8std-mcp.conf +++ /dev/null @@ -1,90 +0,0 @@ -# Include this server block from the nginx `http` context after installing -# the certificate paths for ai.v8std.ru. - -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name ai.v8std.ru; - - # Replace these paths with the managed certificate paths on the host. - ssl_certificate /etc/letsencrypt/live/ai.v8std.ru/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/ai.v8std.ru/privkey.pem; - - access_log /var/log/nginx/ai.v8std.ru.access.log v8std_mcp_timing; - add_header Retry-After $v8std_retry_after always; - - location = /healthz { - proxy_pass http://v8std_mcp_upstream/healthz; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 5s; - } - - location = /version { - proxy_pass http://v8std_mcp_upstream/version; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 5s; - } - - location = /mcp/ { - if ($request_method ~ ^(GET|HEAD)$) { - return 303 /mcp; - } - return 308 /mcp; - } - - location = /mcp { - # Reject the optional unsolicited SSE stream at the edge. The ASGI - # application repeats this check as defense in depth. - if ($v8std_reject_event_stream_get) { - return 405; - } - add_header Allow "POST, HEAD" always; - add_header Retry-After $v8std_retry_after always; - - client_max_body_size 256k; - limit_req zone=v8std_mcp_requests burst=400 nodelay; - limit_req_status 429; - limit_conn v8std_mcp_connections 40000; - limit_conn_status 503; - - # POST responses are request-scoped. A stalled request must not hold - # an edge worker forever, while normal agent tool calls finish well - # inside this deadline. - proxy_connect_timeout 2s; - proxy_send_timeout 10s; - proxy_read_timeout 35s; - proxy_next_upstream off; - proxy_intercept_errors on; - error_page 502 504 =503 @v8std_mcp_backend_unavailable; - proxy_pass http://v8std_mcp_upstream/mcp; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_buffering off; - } - - location @v8std_mcp_backend_unavailable { - default_type application/json; - add_header Retry-After $v8std_retry_after always; - return 503 '{"error":"MCP backend temporarily unavailable; retry the request."}'; - } - - location = / { - return 301 https://v8std.ru/mcp/; - } - - location / { - return 404; - } -} diff --git a/deploy/systemd/v8std-mcp.service b/deploy/systemd/v8std-mcp.service deleted file mode 100644 index 8eb50a4..0000000 --- a/deploy/systemd/v8std-mcp.service +++ /dev/null @@ -1,30 +0,0 @@ -[Unit] -Description=v8std.ru read-only MCP server -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=v8std-mcp -Group=v8std-mcp -WorkingDirectory=/opt/v8std-mcp -Environment=PYTHONUNBUFFERED=1 -Environment=PYTHONPYCACHEPREFIX=/var/lib/v8std-mcp/pycache -ExecStart=/opt/v8std-mcp/venv/bin/python /opt/v8std-mcp/scripts/v8std_mcp_server.py --index-url https://v8std.ru/ai/pages.jsonl --vectors-url https://v8std.ru/ai/search-vectors.jsonl --cache-dir /var/lib/v8std-mcp --host 127.0.0.1 --port 8765 --mcp-path /mcp --max-snippet-chars 4000 --usage-log /var/lib/v8std-mcp/tool-usage.jsonl -Restart=on-failure -RestartSec=5s -TimeoutStopSec=45s -KillMode=mixed -LimitNOFILE=131072 -NoNewPrivileges=true -PrivateTmp=true -ProtectHome=true -ProtectSystem=strict -ReadWritePaths=/var/lib/v8std-mcp -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -RestrictRealtime=true -SystemCallArchitectures=native -UMask=0027 - -[Install] -WantedBy=multi-user.target diff --git a/dev/__init__.py b/dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dev/checks/__init__.py b/dev/checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check_diagnostic_articles.py b/dev/checks/check_diagnostic_articles.py similarity index 89% rename from scripts/check_diagnostic_articles.py rename to dev/checks/check_diagnostic_articles.py index be21579..c0355ed 100644 --- a/scripts/check_diagnostic_articles.py +++ b/dev/checks/check_diagnostic_articles.py @@ -7,26 +7,15 @@ from dataclasses import dataclass from pathlib import Path -try: - from scripts.diagnostic_articles import content_sha256, load_catalog - from scripts.diagnostic_standard_links import ( - SourceProposal, - V8STD_NAVIGATION_RE, - V8STD_URL_RE, - load_reviews, - parse_v8std_url, - validate_review_coverage, - ) -except ModuleNotFoundError: # Direct ``python scripts/...`` execution. - from diagnostic_articles import content_sha256, load_catalog - from diagnostic_standard_links import ( - SourceProposal, - V8STD_NAVIGATION_RE, - V8STD_URL_RE, - load_reviews, - parse_v8std_url, - validate_review_coverage, - ) +from dev.content.diagnostic_articles import content_sha256, load_catalog +from dev.content.diagnostic_standard_links import ( + SourceProposal, + V8STD_NAVIGATION_RE, + V8STD_URL_RE, + load_reviews, + parse_v8std_url, + validate_review_coverage, +) SOURCE_BLOCK_RE = re.compile( @@ -63,7 +52,7 @@ def _source_metadata(raw: str, page: Path) -> dict[str, str]: def verify_committed_articles(root: Path) -> IntegritySummary: catalog = load_catalog(root / "data/diagnostic-sources.json") - reviews = load_reviews(root / "data/diagnostic-standard-links.json") + reviews = load_reviews(root / "dev/content/data/diagnostic-standard-links.json") proposals: set[SourceProposal] = set() diagnostic_ids: set[str] = set() source_urls: dict[str, str] = {} @@ -168,7 +157,7 @@ def main(argv: list[str] | None = None) -> int: description="Verify committed diagnostic article bodies and provenance offline" ) parser.add_argument( - "--root", type=Path, default=Path(__file__).resolve().parents[1] + "--root", type=Path, default=Path(__file__).resolve().parents[2] ) args = parser.parse_args(argv) try: diff --git a/dev/checks/check_mcp_container.py b/dev/checks/check_mcp_container.py new file mode 100644 index 0000000..88ce7bb --- /dev/null +++ b/dev/checks/check_mcp_container.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Disposable HTTP image acceptance. + +Build images first. Requires Docker, Python runtime dependencies, and (with +--chrome) Chrome plus Node 22+. Each run removes only its own labeled resources. +""" +from __future__ import annotations + +import argparse +from functools import cache +import hashlib +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +from urllib.error import HTTPError +from urllib.request import Request, urlopen +import uuid + + +ROOT = Path(__file__).resolve().parents[2] +INIT = {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "v8std-container-check", "version": "1"}} +SIGNAL = 'Предупреждение("Текст");' +TOOLS = {"v8std_search", "v8std_get_page", "v8std_get_related", + "v8std_explain_snippet", "v8std_explain_diagnostics"} +RESOURCE_REQUESTS = [("resources/list", {}), ("resources/templates/list", {})] + [ + (method, {"uri": uri}) + for method in ("resources/read", "resources/subscribe", "resources/unsubscribe") + for uri in ("v8std://llms.txt", "v8std://llms-full.txt", "v8std://ai/pages.jsonl", "v8std://missing")] +# Whole-attempt budget plus bounded interpreter/container startup margin. +# This is only polling readiness; individual RPC/read/stop budgets stay shorter. +STARTUP_SECONDS = 360 + 30 + + +@cache +def canonical_ranking(): + from runtime.v8std_mcp_index import V8StdIndex + index = V8StdIndex(pages_path=ROOT / "docs/ai/pages.jsonl", + vectors_path=ROOT / "docs/ai/search-vectors.jsonl") + index.load() + return [row["id"] for row in index.search("модальные окна", limit=5)["results"]] + + +def run(*args, timeout=180, **kwargs): + return subprocess.check_output(list(map(str, args)), text=True, timeout=timeout, **kwargs).strip() + + +def eventually(check, seconds=120): + end = time.monotonic() + seconds + last = None + while time.monotonic() < end: + try: + result = check() + if result: + return result + except (OSError, ValueError, AssertionError, subprocess.CalledProcessError) as error: + last = error + time.sleep(1) + raise AssertionError(f"readiness deadline: {last}") + + +def http(url, message=None, headers=None): + req = Request(url, data=json.dumps(message).encode() if message else None, + headers={"Accept": "application/json, text/event-stream", + "Content-Type": "application/json", **(headers or {})}) + try: + response = urlopen(req, timeout=10) + except HTTPError as error: + response = error + with response: + data = response.read() + return response.status, dict(response.headers), data + + +def validate_envelope(reply, request_id): + assert isinstance(reply, dict) and reply.get("jsonrpc") == "2.0", reply + assert type(reply.get("id")) is type(request_id) and reply["id"] == request_id, reply + assert ("result" in reply) != ("error" in reply), reply + return reply + + +def successful_result(reply): + assert "error" not in reply and isinstance(reply.get("result"), dict), reply + return reply["result"] + + +def validate_resource_denial(reply, request_id): + validate_envelope(reply, request_id) + assert set(reply) == {"jsonrpc", "id", "error"}, reply + error = reply["error"] + assert isinstance(error, dict) and set(error) == {"code", "message"}, reply + assert error["code"] == -32601 and isinstance(error["message"], str) and error["message"].strip(), reply + assert len(json.dumps(reply, ensure_ascii=False).encode()) < 256, "resource error contains excessive payload" + + +def check_resources_disabled(envelope): + for method, params in RESOURCE_REQUESTS: + reply = envelope(method, params) + # Transport adapters have already matched the typed request ID. + validate_resource_denial(reply, reply["id"]) + return {"probes": len(RESOURCE_REQUESTS), "rejected": len(RESOURCE_REQUESTS), "code": -32601} + + +class HttpRpc: + """The same complete-envelope lifecycle for online and network-none HTTP.""" + def __init__(self, send): + self.send, self.seq, self.headers = send, 0, {} + + def envelope(self, method, params=None): + self.seq += 1 + status, headers, body = self.send({"jsonrpc": "2.0", "id": self.seq, + "method": method, "params": params or {}}, self.headers) + assert status == 200, (status, body[:200]) + media = {key.lower(): value for key, value in headers.items()}.get("content-type", "") + assert media.split(";")[0] == "application/json", media + return validate_envelope(json.loads(body), self.seq) + + def request(self, method, params=None): + return successful_result(self.envelope(method, params)) + + def initialize(self): + reply = self.request("initialize", INIT) + assert reply["serverInfo"]["name"] == "v8std" and "resources" not in reply["capabilities"], reply + assert reply["protocolVersion"] == INIT["protocolVersion"], reply + self.headers["MCP-Protocol-Version"] = reply["protocolVersion"] + status, _, body = self.send({"jsonrpc": "2.0", "method": "notifications/initialized"}, self.headers) + assert status == 202 and not body, (status, body) + return reply + + +def check_default_source(site_url, local_default, *, require_404=False): + """A working alternate source does not imply the default must be broken.""" + if not require_404: + return {} + assert site_url != local_default, "404 regression requires an explicit alternate source" + status = http(local_default + "ai/mcp/v1/manifest.json")[0] + assert status == 404, "override regression requires an unavailable default source" + return {"default_source_status": status} + + + + +def content(reply): + assert not reply.get("isError"), str(reply)[:400] + assert reply.get("content") and all(item["type"] == "text" for item in reply["content"]), reply + decoded = json.loads(reply["content"][0]["text"]) + assert isinstance(decoded, dict), decoded + if "structuredContent" in reply: + assert reply["structuredContent"] == decoded, reply + return decoded + + +def check_tools(request, site_url): + listed = request("tools/list") + names = {tool["name"] for tool in listed["tools"]} + assert names == TOOLS, names + snippet = next(t for t in listed["tools"] if t["name"] == "v8std_explain_snippet") + assert snippet["inputSchema"]["properties"]["snippet"]["maxLength"] == 4000 + def call(name, args): + return content(request("tools/call", {"name": name, "arguments": args})) + search = eventually(lambda: call("v8std_search", {"query": "std437", "limit": 3}), + seconds=STARTUP_SECONDS) + assert search["results"][0]["id"] == "std437", search + assert search["results"][0]["url"] == site_url + "std/437/", search + ranking = call("v8std_search", {"query": "модальные окна", "limit": 5}) + assert [row["id"] for row in ranking["results"]] == canonical_ranking(), ranking + result = call("v8std_explain_snippet", {"snippet": SIGNAL, "limit": 1}) + ids = [row["id"] for field in ("diagnostics", "standards") for row in result[field]] + assert ids == ["bslls:UsingModalWindows"], ids + row = (result["diagnostics"] + result["standards"])[0] + assert any(reason.startswith("snippet_signal:") for reason in row["match_reasons"]), row + page = call("v8std_get_page", {"id_or_alias_or_url": "std437"}) + assert page["found"] and page["page"]["id"] == "std437" and page["page"]["body_markdown"] + assert page["page"]["url"] == site_url + "std/437/" + related = call("v8std_get_related", {"id_or_alias_or_url": "std437"}) + assert related["found"] and related["id"] == "std437" and related["related"], related + assert all(row.get("id") and row.get("relation") and row["url"].startswith(site_url) + for row in related["related"]), related + diagnostics = call("v8std_explain_diagnostics", {"codes": ["bslls:UsingModalWindows"]}) + assert diagnostics["diagnostics"][0]["id"] == "bslls:UsingModalWindows", diagnostics + return [row["id"] for row in search["results"]] + + +# Browser target + worker events are captured before navigation. Request +# interception blocks every foreign origin and records attempts as failures. +CHROME_CHECK = r""" +const [endpoint, base] = process.argv.slice(1); +const socket = new WebSocket(endpoint); +await new Promise(r => socket.addEventListener('open', r, {once:true})); +let seq=0; const pending=new Map(), attached=new Map(), requests=[], failures=[], statuses=[]; +function send(method,params={},sessionId) { + const id=++seq; + return new Promise((resolve,reject)=>{ + pending.set(id,{resolve,reject}); + socket.send(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})); + }); +} +socket.addEventListener('message',async ({data})=>{ + const m=JSON.parse(data); + if(m.id) { const p=pending.get(m.id); pending.delete(m.id); + if(m.error)p.reject(Error(JSON.stringify(m.error)));else p.resolve(m.result);return; } + const p=m.params; + if(m.method==='Target.attachedToTarget') { + await send('Network.enable',{},p.sessionId); + if(p.targetInfo.type==='page') { + await send('Fetch.enable',{patterns:[{urlPattern:'*'}]},p.sessionId); + await send('Target.setAutoAttach', + {autoAttach:true,waitForDebuggerOnStart:true,flatten:true},p.sessionId); + } + attached.set(p.targetInfo.targetId,p.sessionId); + await send('Runtime.runIfWaitingForDebugger',{},p.sessionId); + } + if(m.method==='Network.requestWillBeSent')requests.push(p.request.url); + if(m.method==='Network.responseReceived')statuses.push({url:p.response.url,status:p.response.status}); + if(m.method==='Fetch.requestPaused') { + const u=p.request.url; + if(/^https?:/.test(u) && new URL(u).origin!==new URL(base).origin) { + failures.push(u);await send('Fetch.failRequest',{requestId:p.requestId,errorReason:'BlockedByClient'},m.sessionId); + }else await send('Fetch.continueRequest',{requestId:p.requestId},m.sessionId); + } +}); +await send('Target.setAutoAttach',{autoAttach:true,waitForDebuggerOnStart:true,flatten:true}); +const {targetId}=await send('Target.createTarget',{url:'about:blank'}); +while(!attached.has(targetId))await new Promise(r=>setTimeout(r,10)); +const sessionId=attached.get(targetId); +await send('Page.enable',{},sessionId); +for (const path of ['', 'std/437/', 'diagnostics/bslls/', 'diagnostics/bslls/UsingModalWindows/', 'LICENSES/']) { + const navigation=await send('Page.navigate',{url:base+path},sessionId); + if(navigation.errorText)throw Error(navigation.errorText); + await new Promise(r=>setTimeout(r,2500)); + await send('Runtime.evaluate',{expression:`document.querySelector('input[data-md-component="search-query"]')?.focus()`},sessionId); + await new Promise(r=>setTimeout(r,1500)); +} +const unique=[...new Set(requests.filter(u=>/^https?:/.test(u)))]; +failures.push(...unique.filter(u=>new URL(u).origin!==new URL(base).origin)); +const bad=statuses.filter(x=>x.status>=400); +console.log(JSON.stringify({requests:unique,blocked:failures,badResponses:bad})); +await send('Target.closeTarget',{targetId});socket.close(); +if(failures.length||bad.length||unique.length<10)process.exitCode=1; +""" + + +def browser_graph(chrome, node, site_url, directory): + with (directory / "chrome.log").open("w") as log: + process = subprocess.Popen([chrome, "--headless=new", "--no-first-run", + "--no-default-browser-check", "--disable-background-networking", "--disable-component-update", + "--password-store=basic", "--remote-debugging-port=0", + "--disable-sync", "--disable-default-apps", "--disable-domain-reliability", + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE v8std.localhost, EXCLUDE localhost", + f"--user-data-dir={directory / 'chrome-profile'}", "about:blank"], + stdout=log, stderr=log) + try: + active = directory / "chrome-profile/DevToolsActivePort" + eventually(lambda: active.is_file(), seconds=15) + port, endpoint = active.read_text().splitlines()[:2] + try: + result = run(node, "--input-type=module", "-e", CHROME_CHECK, + f"ws://127.0.0.1:{port}{endpoint}", site_url, timeout=55) + except subprocess.CalledProcessError as error: + raise AssertionError("browser graph: " + error.output[-6000:]) from None + return json.loads(result) + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mcp-image", required=True) + parser.add_argument("--site-image", required=True) + parser.add_argument("--platform", choices=["linux/arm64", "linux/amd64"], required=True) + parser.add_argument("--site-port", type=int, default=18765) + parser.add_argument("--mcp-port", type=int, default=18766) + parser.add_argument("--prefix", default="/") + parser.add_argument("--chrome") + parser.add_argument("--node", default="node") + parser.add_argument("--require-default-source-404", action="store_true", + help="Explicit override regression: additionally require the default manifest to be absent") + args = parser.parse_args() + canonical_ranking() + for port in (args.site_port, args.mcp_port): + with socket.socket() as probe: + # Permit TIME_WAIT from our preceding run, but never an active listener. + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + probe.bind(("127.0.0.1", port)) + assert args.prefix.startswith("/") and args.prefix.endswith("/") + local_default = f"http://v8std.localhost:{args.site_port}{args.prefix}" + site_url = os.environ.get("V8STD_MCP_SITE_URL") or local_default + project = "v8std-task4-" + uuid.uuid4().hex[:10] + env = {**os.environ, "V8STD_SITE_IMAGE": args.site_image, "V8STD_MCP_IMAGE": args.mcp_image, + "V8STD_SITE_PORT": str(args.site_port), "V8STD_MCP_PORT": str(args.mcp_port), + "V8STD_SITE_PREFIX": args.prefix, "DOCKER_DEFAULT_PLATFORM": args.platform} + compose = ["docker", "compose", "-p", project, "-f", str(ROOT / "delivery/local/compose.yaml")] + names = [] + report = {"platform": args.platform, "site_url": site_url, "successful_tools": sorted(TOOLS), + "rejected_resources": {}, "browser": "incomplete: --chrome not supplied"} + resolved = json.loads(run(*compose, "--profile", "mcp", "config", "--format", "json", env=env)) + assert resolved["services"]["mcp"]["environment"]["V8STD_MCP_SITE_URL"] == site_url + report["compose_site_url"] = site_url + with tempfile.TemporaryDirectory(prefix=project) as directory: + directory = Path(directory) + try: + run(*compose, "up", "-d", "site", env=env) + site = run(*compose, "ps", "-q", "site", env=env) + eventually(lambda: http(site_url)[0] == 200) + report.update(check_default_source(site_url, local_default, + require_404=args.require_default_source_404)) + manifest_url = site_url + "ai/mcp/v1/manifest.json" + status, headers, payload = http(manifest_url) + assert status == 200 and headers.get("Cache-Control") == "no-store" + manifest = json.loads(payload) + assert not manifest["archive"]["path"].startswith(("/", "http")) + archive_url = site_url + "ai/mcp/v1/" + manifest["archive"]["path"] + status, headers, archive = http(archive_url) + assert status == 200 and headers["Content-Type"] == "application/gzip" + assert "immutable" in headers["Cache-Control"] + assert hashlib.sha256(archive).hexdigest() == manifest["archive"]["sha256"] + status, headers, _ = http(site_url + "ai/mcp/v1/" + "0" * 64 + "/snapshot.tar.gz") + assert status == 404 and "immutable" not in headers.get("Cache-Control", "") + status, _, _ = http(manifest_url, headers={"If-Modified-Since": "Wed, 31 Dec 2099 23:59:59 GMT"}) + assert status == 200, "manifest must not return stale 304" + assert http(site_url + "LICENSES/")[0] == 200 + for license_name in ("LGPL-3.0", "GPL-3.0", "EPL-2.0"): + assert http(site_url + f"LICENSES/{license_name}.txt")[2] == (ROOT / f"LICENSES/{license_name}.txt").read_bytes() + report["corpus_id"] = manifest["corpus_id"] + report["corpus_source_sha"] = manifest["source_sha"] + report["archive_sha256"] = manifest["archive"]["sha256"] + if args.chrome: + report["browser"] = browser_graph(args.chrome, args.node, site_url, directory) + network = project + "_corpus" + def container(name, network_name, *, cache): + names.append(name) + return ["docker", "run", "--name", name, "--platform", args.platform, + "--label", f"v8std-task4={project}", "-i", "--init", "--read-only", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--cpus", "2", + "--memory", "1536m", "--pids-limit", "128", "--network", network_name, + "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m,uid=10001,gid=10001", + "-v", cache + ":/var/lib/v8std-mcp", "-e", "V8STD_MCP_SITE_URL=" + site_url, + args.mcp_image, "--transport", "streamable-http", "--refresh-seconds", "0"] + + def inspect(name): + state = json.loads(run("docker", "inspect", name))[0] + assert state["Config"]["User"] == "10001:10001" + assert state["Config"]["WorkingDir"] == "/opt/v8std" + assert state["HostConfig"]["ReadonlyRootfs"] + assert state["HostConfig"]["CapDrop"] == ["ALL"] + assert state["HostConfig"]["Init"] + assert all("docker.sock" not in m["Destination"] for m in state["Mounts"]) + return state + + def cache_state(name): + code = "from pathlib import Path; import json; p=Path('/var/lib/v8std-mcp'); " \ + "print(json.dumps({str(f.relative_to(p)): [f.stat().st_uid, f.stat().st_size, f.stat().st_mtime_ns] " \ + "for f in p.rglob('*') if f.is_file() and f.name in ('state.json','snapshot.tar.gz')}))" + return json.loads(run("docker", "exec", name, "python", "-c", code)) + + # Ready/liveness boundary with no source and no cache. + cold = project + "-cold" + command = container(cold, "none", cache=project + "-empty") + process = subprocess.Popen(command + ["--host", "0.0.0.0", "--port", "8000"], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + code = "import urllib.request,urllib.error; " \ + "r=urllib.request.urlopen('http://127.0.0.1:8000/livez'); print(r.status)" + eventually(lambda: run("docker", "exec", cold, "python", "-c", code, + stderr=subprocess.DEVNULL) == "200") + code = "import http.client; c=http.client.HTTPConnection('127.0.0.1',8000); " \ + "c.request('GET','/healthz'); r=c.getresponse(); print(r.status)" + assert run("docker", "exec", cold, "python", "-c", code) == "503" + run("docker", "stop", "-t", "15", cold) + report["cold_http_sigterm_exit"] = process.wait(timeout=20) + assert report["cold_http_sigterm_exit"] in (0, 143) + finally: + if process.poll() is None: + run("docker", "stop", "-t", "15", cold) + process.wait(timeout=20) + process.stdin.close() + + started = time.monotonic() + print("HTTP cold-online: starting", file=sys.stderr, flush=True) + run(*compose, "--profile", "mcp", "up", "-d", env=env) + mcp = run(*compose, "ps", "-q", "mcp", env=env) + endpoint = f"http://127.0.0.1:{args.mcp_port}" + health = eventually(lambda: json.loads(http(endpoint + "/healthz")[2]) if http(endpoint + "/healthz")[0] == 200 else None, + seconds=STARTUP_SECONDS) + report["http_cold_ready_seconds"] = round(time.monotonic() - started, 2) + print("HTTP cold-online: ready", file=sys.stderr, flush=True) + assert health["corpus_id"] == manifest["corpus_id"], health + rpc = HttpRpc(lambda message, headers: http(endpoint + "/mcp", message, headers)) + rpc.initialize() + assert http(endpoint + "/mcp", {"jsonrpc":"2.0", "id":999, + "method":"initialize", "params":INIT}, headers={"Host":"untrusted.invalid"})[0] == 421 + check_tools(rpc.request, site_url) + report["rejected_resources"]["http_online"] = check_resources_disabled(rpc.envelope) + version = json.loads(http(endpoint + "/version")[2]) + assert version["api"] == "v2" and version["api_profiles"] == ["legacy-tools"], version + report["version"] = version + state = inspect(mcp) + expected_sha = json.loads(run("docker", "image", "inspect", args.mcp_image))[0]["Config"]["Labels"]["org.opencontainers.image.revision"] + assert health["runtime_sha"] == expected_sha, health + assert "V8STD_MCP_SITE_URL=" + site_url in state["Config"]["Env"] + http_cache = cache_state(mcp) + http_volume = next(m["Name"] for m in state["Mounts"] if m["Destination"] == "/var/lib/v8std-mcp") + assert list(json.loads(run("docker", "inspect", mcp))[0]["NetworkSettings"]["Networks"]) == [network] + report["http"] = health + report["site_image_id"] = json.loads(run("docker", "inspect", site))[0]["Image"] + run(*compose, "stop", "-t", "15", "mcp", env=env) + report["online_http_sigterm_exit"] = json.loads(run("docker", "inspect", mcp))[0]["State"]["ExitCode"] + assert report["online_http_sigterm_exit"] in (0, 143) + + # Same Compose cache, but no network: exercise real warm HTTP startup. + warm = project + "-warm-http" + started = time.monotonic() + print("HTTP warm-network-none: starting", file=sys.stderr, flush=True) + with (directory / "warm-http.log").open("w") as log: + process = subprocess.Popen(container(warm, "none", cache=http_volume) + + ["--host", "0.0.0.0", "--port", "8000"], stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, stderr=log) + try: + code = "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/healthz',timeout=10).read().decode())" + warm_health = eventually(lambda: json.loads(run("docker", "exec", warm, "python", "-c", code, + stderr=subprocess.DEVNULL)), seconds=STARTUP_SECONDS) + assert warm_health["corpus_id"] == health["corpus_id"] + assert warm_health["runtime_sha"] == expected_sha + def warm_send(message, headers): + code = "import json,sys,urllib.request; r=urllib.request.Request('http://127.0.0.1:8000/mcp',data=sys.argv[1].encode(),headers={'Content-Type':'application/json','Accept':'application/json, text/event-stream',**json.loads(sys.argv[2])}); response=urllib.request.urlopen(r,timeout=10); print(json.dumps([response.status,dict(response.headers),response.read().decode()]))" + status, headers, body = json.loads(run("docker", "exec", warm, "python", "-c", code, + json.dumps(message), json.dumps(headers))) + return status, headers, body.encode() + warm_rpc = HttpRpc(warm_send) + warm_rpc.initialize() + check_tools(warm_rpc.request, site_url) + report["rejected_resources"]["http_warm_offline"] = check_resources_disabled(warm_rpc.envelope) + assert cache_state(warm) == http_cache, "offline warm HTTP rewrote cache" + assert inspect(warm)["HostConfig"]["NetworkMode"] == "none" + report["http_warm_ready_seconds"] = round(time.monotonic() - started, 2) + report["http_warm"] = warm_health + print("HTTP warm-network-none: ready", file=sys.stderr, flush=True) + run("docker", "stop", "-t", "15", warm) + report["warm_http_sigterm_exit"] = process.wait(timeout=20) + assert report["warm_http_sigterm_exit"] in (0, 143) + finally: + if process.poll() is None: + run("docker", "stop", "-t", "15", warm) + process.wait(timeout=20) + process.stdin.close() + report["limits"] = {"mcp_memory_bytes": 1536 * 1024**2, "mcp_cpus": 2, + "site_memory_bytes": 128 * 1024**2, "site_cpus": 0.5} + print(json.dumps(report, ensure_ascii=False, indent=2)) + except BaseException: + for log in directory.glob("*.log"): + print(f"{log.name}: {log.read_text(errors='replace')[-2500:]}", flush=True) + raise + finally: + for name in names: + subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + run(*compose, "--profile", "mcp", "down", "-v", env=env) + for volume_name in (project + "-empty",): + subprocess.run(["docker", "volume", "rm", volume_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +if __name__ == "__main__": + main() diff --git a/dev/checks/check_mcp_load.py b/dev/checks/check_mcp_load.py new file mode 100644 index 0000000..a9766f8 --- /dev/null +++ b/dev/checks/check_mcp_load.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""Bounded disposable local acceptance, not a production capacity benchmark. + +Uses prebuilt exact-source images, private fixture logging, retained API budgets +and real corpus reads. No credentials, Docker socket mount or global setup. +""" +from __future__ import annotations + +import argparse +import asyncio +from collections import Counter +import hashlib +import json +import math +import os +from pathlib import Path +import re +import socket +import tempfile +import time +import uuid + +import httpx + +from delivery.ci.publish_mcp_artifacts import bounded_command, require, save +from dev.checks.check_mcp_container import (RESOURCE_REQUESTS, content, validate_envelope, validate_resource_denial) +from runtime.v8std_mcp_snapshot_format import MAX_ARCHIVE_BYTES, validate_manifest, verify_archive + +ROOT = Path(__file__).resolve().parents[2] +INIT = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "bounded-load", "version": "1"}}} +RESOURCE_PROBE = ("import json,pathlib,os; p=pathlib.Path('/sys/fs/cgroup'); " + "files=['memory.current','memory.peak','memory.events','cpu.stat']; " + "out={n:(p/n).read_text() for n in files}; out['fd_count']=0; out['runtime_pids']=[]\n" + "for d in pathlib.Path('/proc').iterdir():\n" + " if d.name.isdigit():\n" + " try:\n" + " if d.stat().st_uid==os.getuid(): out['fd_count']+=len(list((d/'fd').iterdir())); out['runtime_pids'].append(int(d.name))\n" + " except (PermissionError,FileNotFoundError): pass\n" + "print(json.dumps(out))") + + +def request(number): + slot = number % 20 + if slot < 7: + kind, tool, args = "search", "v8std_search", {"query": "модальные окна", "limit": 5} + elif slot < 12: + kind, tool, args = "page", "v8std_get_page", {"id_or_alias_or_url": "std437"} + elif slot < 15: + kind, tool, args = "snippet", "v8std_explain_snippet", {"snippet": 'Предупреждение("Текст");', "limit": 5} + elif slot < 17: + kind, tool, args = "diagnostics", "v8std_explain_diagnostics", {"codes": ["bslls:UsingModalWindows"]} + else: + kind, tool, args = "related", "v8std_get_related", {"id_or_alias_or_url": "std437"} + return kind, {"jsonrpc": "2.0", "id": number + 2, "method": "tools/call", "params": {"name": tool, "arguments": args}} + + +def valid_reply(kind, body): + try: + reply = json.loads(body) + validate_envelope(reply, reply["id"]) + result = reply["result"] + if "error" in reply or result.get("isError"): + return False + if kind == "initialize": + return (result.get("serverInfo", {}).get("name") == "v8std" + and result.get("protocolVersion") == INIT["params"]["protocolVersion"] + and isinstance(result.get("capabilities"), dict) and "resources" not in result["capabilities"]) + payload = content(result) + if kind == "search": + return bool(payload.get("results")) and all(row.get("id") for row in payload["results"]) + if kind == "page": + return (payload.get("found") is True and payload.get("page", {}).get("id") == "std437" + and bool(payload["page"].get("body_markdown"))) + if kind == "related": + return (payload.get("found") is True and payload.get("id") == "std437" + and bool(payload.get("related")) + and all(row.get("id") and row.get("relation") and row.get("url") for row in payload["related"])) + if kind in {"snippet", "diagnostics"}: + return any(row.get("id") == "bslls:UsingModalWindows" for row in payload.get("diagnostics", [])) + return False + except (AssertionError, AttributeError, KeyError, ValueError, TypeError, IndexError): + return False + + +def valid_wire_reply(kind, body, message, media): + try: + require(media.split(";")[0] == "application/json", "json_only_response") + reply = validate_envelope(json.loads(body), message["id"]) + if kind == "resource_denial": + validate_resource_denial(reply, message["id"]) + return True + return valid_reply(kind, body) + except (AssertionError, KeyError, TypeError, ValueError): + return False + + +def summarize(rows, seconds): + success = sorted(row["seconds"] for row in rows if row["outcome"] == "ok") + def percentile(percent, values=success): + return round(values[max(0, math.ceil(len(values) * percent) - 1)], 6) if values else None + kinds = {} + for kind in sorted({row["kind"] for row in rows}): + group = [row for row in rows if row["kind"] == kind] + times = sorted(row["seconds"] for row in group if row["outcome"] == "ok") + kinds[kind] = {"requests": len(group), "successes": len(times), "success_p95_seconds": percentile(.95, times), + "success_p99_seconds": percentile(.99, times), "response_bytes": sum(row["bytes"] for row in group)} + return {"requests": len(rows), "admitted_successes": len(success), + "retryable_429_503": sum(row["outcome"] == "admission" for row in rows), + "unexpected_errors": sum(row["outcome"] not in {"ok", "admission"} for row in rows), + "successful_rps": round(len(success) / seconds, 3), "success_p95_seconds": percentile(.95), + "success_p99_seconds": percentile(.99), "response_bytes": sum(row["bytes"] for row in rows), + "statuses": dict(Counter(str(row["status"]) for row in rows)), + "outcomes": dict(Counter(row["outcome"] for row in rows)), + "by_kind": kinds} + + +def edge_config(upstream): + require(upstream in {"mcp-a", "mcp-b"}, "fixture_upstream") + http = (ROOT / "delivery/vps/nginx/edge-http.conf").read_text() + http = http.replace("include /etc/nginx/v8std-release/upstream.conf;", + "server " + upstream + ":8000;") + locations = (ROOT / "delivery/vps/nginx/edge-locations.conf").read_text() + server = "server { listen 8000; server_name localhost;\n" + locations + "\n}\n" + return ("worker_processes 1; worker_rlimit_nofile 131072; pid /tmp/nginx.pid;\n" + "error_log /dev/stderr warn; events { worker_connections 65536; }\nhttp {\n" + "access_log off; client_body_temp_path /tmp/client; proxy_temp_path /tmp/proxy;\n" + "fastcgi_temp_path /tmp/fastcgi; uwsgi_temp_path /tmp/uwsgi; scgi_temp_path /tmp/scgi;\n" + + http + server + "\n}\n") + + +class Stack: + """Own only exact UUID-labelled fixtures, including uncertain failed starts.""" + def __init__(self): + self.prefix = "v8std-load-" + uuid.uuid4().hex[:16] + self.owned = [] + + def docker(self, *args, seconds=30): + return bounded_command(["docker", *map(str, args)], seconds=seconds, limit=2 * 1024 * 1024).decode() + + def __enter__(self): + return self + + def __exit__(self, kind, primary, traceback): + failures = [] + for resource in ("container", "volume", "network"): + for item, name in self.owned: + if item != resource: + continue + try: + query = (resource, "ls", "--filter", "name=" + ("^/" + name + "$" if item == "container" else name), + "--format", "{{.Names}}" if item == "container" else "{{.Name}}") + if item == "container": + query += ("-a",) + names = self.docker(*query).splitlines() + if not names: + continue + require(names == [name], "cleanup_exact_target") + info = json.loads(self.docker(*(("inspect", name) if item == "container" else (item, "inspect", name))))[0] + labels = info["Config"]["Labels"] if item == "container" else info["Labels"] + require(labels.get("pro.v8std.load") == self.prefix, "cleanup_ownership") + self.docker(*(("rm", "-f", name) if item == "container" else (item, "rm", name))) + require(not self.docker(*query).strip(), "cleanup_remaining") + except Exception as error: + failures.append(item + " " + name + ": " + str(error)) + if failures: + message = "owned load fixture cleanup failed: " + "; ".join(failures) + if primary is None: + raise RuntimeError(message) + primary.add_note(message) + return False + + def create(self, kind, suffix, *args): + name = self.prefix + "-" + suffix + self.owned.append((kind, name)) + self.docker(kind, "create", "--label", "pro.v8std.load=" + self.prefix, *args, name) + return name + + def launch(self, suffix, image, *args, command=(), memory="1536m", cpus="2", user="10001:10001", networks=()): + name = self.prefix + "-" + suffix + self.owned.append(("container", name)) + self.docker("create", "--name", name, "--pull=never", "--label", "pro.v8std.load=" + self.prefix, + "--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--init", "--user", user, + "--memory", memory, "--memory-swap", memory, "--cpus", cpus, "--pids-limit", "128", + "--tmpfs", "/tmp:rw,noexec,nosuid,size=32m,uid=10001,gid=10001", *args, image, *command, seconds=45) + for network, alias in networks: + self.docker("network", "connect", "--alias", alias, network, name) + self.docker("start", name) + return name + + def inspect(self, name): + return json.loads(self.docker("inspect", name))[0] + + +async def measure(client, url, kind, message=None, *, headers=None): + started = time.monotonic() + row = {"kind": kind, "status": 0, "bytes": 0, "retry_after": None} + try: + async with client.stream("POST" if message else "GET", url, json=message, headers=headers) as response: + row.update(status=response.status_code, retry_after=response.headers.get("retry-after")) + chunks = [] + async for chunk in response.aiter_bytes(): + row["bytes"] += len(chunk) + require(row["bytes"] <= 64 * 1024 * 1024, "load_client_response_bound") + if message: + chunks.append(chunk) + if row["status"] in {429, 503}: + row["outcome"] = "admission" if row["retry_after"] else "overload_without_retry_after" + elif row["status"] != 200: + row["outcome"] = "http_error" + else: + row["outcome"] = "ok" if not message or valid_wire_reply( + kind, b"".join(chunks), message, response.headers.get("content-type", "")) else "mcp_error" + except httpx.TimeoutException: + row["outcome"] = "timeout" + except httpx.HTTPError: + row["outcome"] = "transport_error" + row["seconds"] = time.monotonic() - started + return row + + +def stage_snapshot(directory, destination): + manifest = validate_manifest((directory / "manifest.json").read_bytes()) + name = manifest["archive"]["sha256"] + "/snapshot.tar.gz" + archive = (directory / name).read_bytes() + require(len(archive) <= MAX_ARCHIVE_BYTES, "fixture_archive_size") + verified = verify_archive(archive, manifest) + path = destination / name + path.parent.mkdir(parents=True, exist_ok=True) + path.parent.chmod(0o755) + path.write_bytes(archive) + path.chmod(0o644) + manifest["archive"]["path"] = name + rows = [json.loads(line) for line in verified.files["pages.jsonl"].splitlines() if line.strip()] + page = next(row for row in rows if row["id"] == "std437") + body = page["body_markdown"] + # These explicitly synthetic fixtures use a bounded, literal page body. + # Hash archive content directly, never the runtime presentation algorithm. + require(isinstance(body, str) and 0 < len(body) <= 12000, "controlled_page_within_default_budget") + return manifest, hashlib.sha256(body.encode("utf-8")).hexdigest() + + +def pointer(path, manifest): + save(path, manifest) + path.chmod(0o644) + + +def write_report(path, report): + # Local measurements contain finite fractional seconds; they are not the + # integer-only canonical snapshot/publication contract. + payload = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(payload, encoding="utf-8") + + +async def open_idle(port): + reader, writer = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", port), 3) + try: + writer.write(b"GET /healthz HTTP/1.1\r\nHost: ai.v8std.ru\r\nConnection: keep-alive\r\n\r\n") + await writer.drain() + header = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5) + size = re.search(rb"(?im)^content-length:\s*([0-9]+)", header) + require(b" 200 " in header and size is not None and int(size[1]) <= 65536, "idle_probe") + await asyncio.wait_for(reader.readexactly(int(size[1])), 5) + return reader, writer + except BaseException: + writer.close() + await writer.wait_closed() + raise + + +async def maintain_idle(idle, port, stop, stats, *, interval=.25): + while not stop.is_set(): + for index, (reader, writer) in enumerate(idle): + if reader.at_eof(): + writer.close() + await writer.wait_closed() + idle[index] = await open_idle(port) + stats["reconnections"] += 1 + try: + await asyncio.wait_for(stop.wait(), interval) + except TimeoutError: + pass + + +async def page_content_hash(client, endpoint, expected): + _, message = request(7) + async with client.stream("POST", endpoint + "/mcp", json=message) as response: + require(response.status_code == 200, "page_hash_http") + chunks, size = [], 0 + async for chunk in response.aiter_bytes(): + size += len(chunk) + require(size <= 256 * 1024, "page_hash_bound") + chunks.append(chunk) + body = b"".join(chunks) + require(valid_wire_reply("page", body, message, response.headers.get("content-type", "")), "page_hash_reply") + page = content(json.loads(body)["result"])["page"] + require(page.get("body_truncated") is False, "controlled_page_truncated") + actual = hashlib.sha256(page["body_markdown"].encode("utf-8")).hexdigest() + require(actual == expected, "controlled_page_content_mismatch") + return actual + + +async def profile(stack, args, directory, names, manifests, page_hashes, endpoint, static_url): + rows, samples, events = [], [], [] + headers = {"Host": "ai.v8std.ru", "Accept": "application/json, text/event-stream"} + limits = httpx.Limits(max_connections=64, max_keepalive_connections=64) + async with httpx.AsyncClient(timeout=15, trust_env=False, limits=limits, headers=headers) as client: + deadline = time.monotonic() + 390 + health = None + while time.monotonic() < deadline: + try: + response = await client.get(endpoint + "/healthz") + health = response.json() + if response.status_code == 200 and health.get("corpus_id") == manifests[0]["corpus_id"]: + break + except (httpx.HTTPError, ValueError): + pass + await asyncio.sleep(.5) + require(health is not None and health.get("runtime_sha") == args.source_sha + and health.get("corpus_id") == manifests[0]["corpus_id"], "load_initial_readiness") + initial = await measure(client, endpoint + "/mcp", "initialize", INIT) + require(initial["outcome"] == "ok", "load_initialize") + client.headers["MCP-Protocol-Version"] = INIT["params"]["protocolVersion"] + notified = await client.post(endpoint + "/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"}) + require(notified.status_code == 202 and not notified.content, "load_initialized_notification") + async def denied_probes(): + probes = [] + for number, (method, params) in enumerate(RESOURCE_REQUESTS): + message = {"jsonrpc": "2.0", "id": "denied-" + str(number), "method": method, "params": params} + row = await measure(client, endpoint + "/mcp", "resource_denial", message) + row.update(method=method, params=params) + probes.append(row) + require(all(row["outcome"] == "ok" for row in probes), "load_resources_not_denied") + return probes + negative_before = await denied_probes() + before_hash = await page_content_hash(client, endpoint, page_hashes[0]) + # Burst is discovery traffic, deliberately separate from data-call mix. + burst_start = time.monotonic() + semaphore = asyncio.Semaphore(64) + async def burst_request(): + async with semaphore: + return await measure(client, endpoint + "/mcp", "initialize", INIT) + burst = await asyncio.gather(*(burst_request() for _ in range(args.burst))) + burst_seconds = time.monotonic() - burst_start + await asyncio.sleep(5) # Existing Retry-After/admission budget, not suppressed errors. + idle = [] + idle_stop, idle_stats = asyncio.Event(), {"reconnections": 0} + idle_keeper = None + primary = None + port = int(endpoint.rsplit(":", 1)[1]) + try: + for _ in range(args.idle): + idle.append(await open_idle(port)) + idle_keeper = asyncio.create_task(maintain_idle(idle, port, idle_stop, idle_stats)) + started = time.monotonic() + reconnects = 0 + maximum_inflight = inflight = 0 + async def worker(worker_id): + nonlocal reconnects, maximum_inflight, inflight + number = worker_id * 3 + while time.monotonic() - started < args.seconds: + label, message = request(number) + close = number % 11 == 0 + reconnects += close + inflight += 1 + maximum_inflight = max(maximum_inflight, inflight) + try: + rows.append(await measure(client, endpoint + "/mcp", label, message, + headers={"Connection": "close"} if close else None)) + finally: + inflight -= 1 + number += 1 + await asyncio.sleep(.05) + async def downloads(): + while time.monotonic() - started < args.seconds: + for manifest in manifests: + rows.append(await measure(client, static_url + "/ai/mcp/v1/" + manifest["archive"]["path"], "archive")) + await asyncio.sleep(.2) + async def transitions(): + await asyncio.sleep(args.seconds / 3) + pointer(directory / "source/manifest.json", manifests[1]) + events.append({"action": "refresh_manifest", "seconds": round(time.monotonic() - started, 3), + "corpus_id": manifests[1]["corpus_id"]}) + await asyncio.sleep(args.seconds / 3) + (directory / "edge/nginx.conf").write_text(edge_config("mcp-b")) + await asyncio.to_thread(stack.docker, "exec", names["edge"], "nginx", "-t", "-c", "/fixture/nginx.conf") + await asyncio.to_thread(stack.docker, "exec", names["edge"], "nginx", "-s", "reload", "-c", "/fixture/nginx.conf") + events.append({"action": "switch_to_second_process_same_image", "seconds": round(time.monotonic() - started, 3)}) + async def sample(): + while time.monotonic() - started < args.seconds: + raw = await asyncio.to_thread(stack.docker, "stats", "--no-stream", "--format", "{{json .}}", + *names.values(), seconds=15) + samples.append({"seconds": round(time.monotonic() - started, 3), + "containers": [json.loads(line) for line in raw.splitlines()], + "runtime_cgroups": {slot: json.loads(await asyncio.to_thread(stack.docker, "exec", names[slot], + "python", "-c", RESOURCE_PROBE, seconds=10)) for slot in ("a", "b")}}) + await asyncio.sleep(1) + async with asyncio.timeout(args.seconds + 35): + await asyncio.gather(*(worker(number) for number in range(args.clients)), downloads(), transitions(), sample()) + elapsed = time.monotonic() - started + final = (await client.get(endpoint + "/healthz")).json() + require(final.get("ok") is True and final.get("runtime_sha") == args.source_sha + and final.get("corpus_id") == manifests[1]["corpus_id"], "refresh_and_switch_readiness") + after_hash = await page_content_hash(client, endpoint, page_hashes[1]) + negative_after = await denied_probes() + data = [row for row in rows if row["kind"] != "archive"] + return {"scope": "local HTTP (no TLS), one edge worker, two processes of the same exact image; not host-controller deployment", + "duration_seconds": elapsed, "configured_clients": args.clients, "maximum_inflight_data_calls": maximum_inflight, + "idle_connections_opened": len(idle), "idle_connections_still_open": sum(not reader.at_eof() for reader, _ in idle), + "idle_reconnections": idle_stats["reconnections"], "idle_maintenance_interval_seconds": .25, + "page_content_hashes": {"before": before_hash, "after": after_hash}, + "rejected_resource_probes": {"before": negative_before, "after": negative_after, + "requests": len(negative_before) + len(negative_after), + "rejected": len(negative_before) + len(negative_after), "unexpected_errors": 0}, + "initialize": initial, + "connection_close_requests": reconnects, "shared_nat": "all clients share one host address at edge", + "data": summarize(data, elapsed), "static": summarize([row for row in rows if row["kind"] == "archive"], elapsed), + "discovery_burst": summarize(burst, max(.001, burst_seconds)), "events": events, + "samples": samples, "initial_health": health, "final_health": final} + except BaseException as error: + primary = error + raise + finally: + idle_stop.set() + if idle_keeper is not None: + # A failed reconnect must fail the profile, but not skip socket cleanup. + await asyncio.gather(idle_keeper, return_exceptions=True) + for _, writer in idle: + writer.close() + await asyncio.gather(*(writer.wait_closed() for _, writer in idle), return_exceptions=True) + if idle_keeper is not None and not idle_keeper.cancelled() and idle_keeper.exception() is not None: + if primary is None: + raise idle_keeper.exception() + primary.add_note("idle maintenance failed: " + str(idle_keeper.exception())) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mcp-image", required=True) + parser.add_argument("--site-image", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--snapshot", required=True, type=Path) + parser.add_argument("--refresh-snapshot", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--seconds", type=int, default=60) + parser.add_argument("--clients", type=int, default=8) + parser.add_argument("--idle", type=int, default=64) + parser.add_argument("--burst", type=int, default=600) + args = parser.parse_args(argv) + require(re.fullmatch(r"[0-9a-f]{40}", args.source_sha) and 30 <= args.seconds <= 120 + and 1 <= args.clients <= 32 and 0 <= args.idle <= 256 and 0 <= args.burst <= 1000, "load_bounds") + require(not args.output.exists(), "evidence_output_exists") + with socket.socket() as port_probe: + port_probe.bind(("127.0.0.1", 18765)) + with tempfile.TemporaryDirectory(prefix="v8std-load-source-") as temporary, Stack() as stack: + directory = Path(temporary) + for name in ("source", "edge"): + (directory / name).mkdir(mode=0o755) + staged = [stage_snapshot(path, directory / "source") for path in (args.snapshot, args.refresh_snapshot)] + manifests, page_hashes = [item[0] for item in staged], [item[1] for item in staged] + require(manifests[0]["corpus_id"] != manifests[1]["corpus_id"], "distinct_refresh_fixture_required") + require(page_hashes[0] != page_hashes[1], "changed_page_content_required") + pointer(directory / "source/manifest.json", manifests[0]) + (directory / "edge/nginx.conf").write_text(edge_config("mcp-a")) + (directory / "edge/nginx.conf").chmod(0o644) + image_info = json.loads(stack.docker("image", "inspect", args.mcp_image, args.site_image)) + require(len(image_info) == 2 and all(info["Config"]["Labels"].get("org.opencontainers.image.revision") == args.source_sha + for info in image_info), "committed_image_source") + corpus = stack.create("network", "corpus", "--internal") + publish = stack.create("network", "publish") + logs = stack.create("volume", "logs") + mount = json.loads(stack.docker("volume", "inspect", logs))[0]["Mountpoint"] + require(mount.startswith("/var/lib/docker/volumes/" + logs + "/"), "owned_volume_mountpoint") + # Only this bounded fixture preparer is root, with CHOWN on its own + # empty volume. MCP receives a single file bind, never this directory. + root_code = ("import os,time; os.mkdir('/fixture/private',0o700); " + "f=os.open('/fixture/private/usage.jsonl',os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o640); " + "os.fchmod(f,0o640); os.fchown(f,10001,0); os.close(f); time.sleep(1200)") + root = stack.launch("prepare", args.mcp_image, "--network=none", "--cap-add=CHOWN", "--entrypoint=python", + "--mount", f"type=volume,source={logs},target=/fixture", command=("-c", root_code), + memory="64m", cpus="0.25", user="0:0") + stack.docker("exec", root, "python", "-c", "import time,pathlib; p=pathlib.Path('/fixture/private/usage.jsonl'); " + "end=time.monotonic()+5\nwhile not p.exists() and time.monotonic() /tmp/site.conf; " + "exec nginx -c /tmp/site.conf -g 'daemon off;'"), + memory="128m", cpus="0.5", networks=((corpus, "v8std.localhost"),)) + for slot in ("a", "b"): + names[slot] = stack.launch(slot, args.mcp_image, "--network", corpus, "--network-alias", "mcp-" + slot, + "--tmpfs", "/var/lib/v8std-mcp:rw,size=256m,uid=10001,gid=10001,mode=0700", + "--mount", f"type=bind,source={mount}/private/usage.jsonl,target=/var/log/v8std-mcp-usage.jsonl", + command=("--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000", + "--site-url", "http://v8std.localhost:18765/", "--refresh-seconds", "5", + "--usage-log", "/var/log/v8std-mcp-usage.jsonl")) + names["edge"] = stack.launch("edge", args.site_image, "--network", publish, "-p", "127.0.0.1::8000", + "--ulimit", "nofile=131072:131072", "--mount", f"type=bind,source={directory / 'edge'},target=/fixture,readonly", + command=("-c", "/fixture/nginx.conf", "-g", "daemon off;"), memory="128m", cpus="1", + networks=((corpus, "edge"),)) + port = stack.inspect(names["edge"])["NetworkSettings"]["Ports"]["8000/tcp"][0]["HostPort"] + print("owned mixed-load fixtures starting: " + stack.prefix, flush=True) + report = asyncio.run(profile(stack, args, directory, names, manifests, page_hashes, + "http://127.0.0.1:" + port, "http://127.0.0.1:18765")) + report.update(source_sha=args.source_sha, image_ids=[info["Id"] for info in image_info], + fixture_prefix=stack.prefix, source_manifests=manifests, refresh_seconds=5, + limits={"runtime_each_memory_bytes": 1536 * 1024**2, "runtime_each_cpus": 2, + "static_memory_bytes": 128 * 1024**2, "edge_memory_bytes": 128 * 1024**2, + "edge_cpus": 1, "root_fixture_memory_bytes": 64 * 1024**2}) + report["runtime_cgroups"] = {slot: json.loads(stack.docker("exec", names[slot], "python", "-c", RESOURCE_PROBE)) for slot in ("a", "b")} + log_probe = ("import json,pathlib,collections; p=pathlib.Path('/fixture/private/usage.jsonl'); " + "s=p.stat(); c=collections.Counter(json.loads(line)['tool'] for line in p.read_text().splitlines()); " + "print(json.dumps({'bytes':s.st_size,'uid':s.st_uid,'gid':s.st_gid,'mode':s.st_mode&511,'tool_counts':c}))") + report["usage_log"] = json.loads(stack.docker("exec", root, "python", "-c", log_probe)) + require(report["usage_log"]["bytes"] > 0 and report["usage_log"]["uid"] == 10001 + and report["usage_log"]["mode"] == 0o640, "private_load_logging") + for slot in ("a", "b"): + require(not stack.inspect(names[slot])["State"]["OOMKilled"], "load_oom") + report["cleanup"] = "verified removal of every exact labelled container, network and log volume; temporary source removed" + write_report(args.output, report) + compact = {key: report[key] for key in ("data", "static", "discovery_burst", "usage_log", "cleanup")} + print(json.dumps(compact, ensure_ascii=False, indent=2)) + require(all(report[key]["unexpected_errors"] == 0 for key in ("data", "static", "discovery_burst")), "load_unexpected_errors") + require(all(value["successes"] > 0 for value in report["data"]["by_kind"].values()) + and set(report["data"]["by_kind"]) == {"search", "page", "snippet", "diagnostics", "related"}, "load_incomplete_mix") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/search_benchmark.py b/dev/checks/search_benchmark.py similarity index 98% rename from scripts/search_benchmark.py rename to dev/checks/search_benchmark.py index 3f4b8de..73017fa 100755 --- a/scripts/search_benchmark.py +++ b/dev/checks/search_benchmark.py @@ -12,11 +12,8 @@ import yaml -SCRIPT_DIR = Path(__file__).resolve().parent -if str(SCRIPT_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPT_DIR)) -from v8std_mcp_index import V8StdIndex # noqa: E402 +from runtime.v8std_mcp_index import V8StdIndex # noqa: E402 def parse_args() -> argparse.Namespace: diff --git a/scripts/search_feedback_cases.py b/dev/checks/search_feedback_cases.py similarity index 100% rename from scripts/search_feedback_cases.py rename to dev/checks/search_feedback_cases.py diff --git a/scripts/snippet_benchmark.py b/dev/checks/snippet_benchmark.py similarity index 94% rename from scripts/snippet_benchmark.py rename to dev/checks/snippet_benchmark.py index 2e9ab61..cceb3e7 100644 --- a/scripts/snippet_benchmark.py +++ b/dev/checks/snippet_benchmark.py @@ -13,10 +13,10 @@ from pathlib import Path from unittest.mock import patch -from search_benchmark import percentile, read_case_payloads -from v8std_mcp_index import V8StdIndex +from dev.checks.search_benchmark import percentile, read_case_payloads +from runtime.v8std_mcp_index import V8StdIndex -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def summarize_series(series: list[list[float]]) -> dict: @@ -39,7 +39,8 @@ def historical_module(ref: str, path: str, name: str) -> types.ModuleType: def load_baseline(ref: str): rules = historical_module(ref, "scripts/v8std_retrieval_rules.py", "_snippet_baseline_rules") - with patch.dict(sys.modules, {"v8std_retrieval_rules": rules}): + features = historical_module(ref, "scripts/v8std_search_features.py", "_snippet_baseline_features") + with patch.dict(sys.modules, {"v8std_retrieval_rules": rules, "v8std_search_features": features}): return historical_module(ref, "scripts/v8std_mcp_index.py", "_snippet_baseline_index").V8StdIndex diff --git a/dev/content/__init__.py b/dev/content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/acc_diagnostics.py b/dev/content/acc_diagnostics.py similarity index 97% rename from scripts/acc_diagnostics.py rename to dev/content/acc_diagnostics.py index f3ed790..fcefd1c 100644 --- a/scripts/acc_diagnostics.py +++ b/dev/content/acc_diagnostics.py @@ -10,22 +10,13 @@ from pathlib import Path from typing import Any, Iterable -try: - from scripts.diagnostic_standard_links import ( - LinkReview, - heading_anchor, - parse_v8std_url, - render_diagnostic_relations, - _standard_heading_anchors, - ) -except ModuleNotFoundError: # Direct ``python scripts/...`` execution. - from diagnostic_standard_links import ( - LinkReview, - heading_anchor, - parse_v8std_url, - render_diagnostic_relations, - _standard_heading_anchors, - ) +from dev.content.diagnostic_standard_links import ( + LinkReview, + heading_anchor, + parse_v8std_url, + render_diagnostic_relations, + _standard_heading_anchors, +) SHA256_RE = re.compile(r"^[0-9a-f]{64}$") @@ -464,7 +455,7 @@ def render_index( def generate(root: Path, *, write: bool, prune: bool = False) -> tuple[int, int, int, bool]: catalog = load_catalog(root / "data/acc-diagnostics.json") validate_site_catalog(catalog) - overrides = load_overrides(root / "data/acc-standard-link-overrides.json") + overrides = load_overrides(root / "dev/content/data/acc-standard-link-overrides.json") reviews = build_link_reviews(catalog, overrides) standards_directory = root / "docs/std" validate_review_targets(reviews, standards_directory) @@ -533,7 +524,7 @@ def main(argv: list[str] | None = None) -> int: help="delete stale numeric ACC pages (requires --write)", ) generate_parser.add_argument( - "--root", type=Path, default=Path(__file__).resolve().parents[1] + "--root", type=Path, default=Path(__file__).resolve().parents[2] ) args = parser.parse_args(argv) diff --git a/scripts/autoformat_fixes.py b/dev/content/autoformat_fixes.py similarity index 100% rename from scripts/autoformat_fixes.py rename to dev/content/autoformat_fixes.py diff --git a/data/acc-standard-link-overrides.json b/dev/content/data/acc-standard-link-overrides.json similarity index 100% rename from data/acc-standard-link-overrides.json rename to dev/content/data/acc-standard-link-overrides.json diff --git a/data/autoformat-fixes.json b/dev/content/data/autoformat-fixes.json similarity index 100% rename from data/autoformat-fixes.json rename to dev/content/data/autoformat-fixes.json diff --git a/data/diagnostic-standard-links.json b/dev/content/data/diagnostic-standard-links.json similarity index 100% rename from data/diagnostic-standard-links.json rename to dev/content/data/diagnostic-standard-links.json diff --git a/data/standard-english-sources.json b/dev/content/data/standard-english-sources.json similarity index 100% rename from data/standard-english-sources.json rename to dev/content/data/standard-english-sources.json diff --git a/scripts/diagnostic_articles.py b/dev/content/diagnostic_articles.py similarity index 100% rename from scripts/diagnostic_articles.py rename to dev/content/diagnostic_articles.py diff --git a/scripts/diagnostic_standard_links.py b/dev/content/diagnostic_standard_links.py similarity index 98% rename from scripts/diagnostic_standard_links.py rename to dev/content/diagnostic_standard_links.py index 3bd47c7..d08e8e4 100644 --- a/scripts/diagnostic_standard_links.py +++ b/dev/content/diagnostic_standard_links.py @@ -8,10 +8,7 @@ from typing import Any, Iterable from urllib.parse import parse_qs, urlsplit -try: - from scripts.diagnostic_articles import SourceCatalog, load_catalog, verify_checkout_revision -except ModuleNotFoundError: # Direct ``python scripts/...`` execution. - from diagnostic_articles import SourceCatalog, load_catalog, verify_checkout_revision +from dev.content.diagnostic_articles import SourceCatalog, load_catalog, verify_checkout_revision STANDARD_RE = re.compile(r"^std\d+$") @@ -575,7 +572,7 @@ def main() -> int: parser.add_argument("--catalog", type=Path, default=Path("data/diagnostic-sources.json")) parser.add_argument("--bslls-checkout", type=Path, required=True) parser.add_argument("--v8-code-style-checkout", type=Path, required=True) - parser.add_argument("--output", type=Path, default=Path("data/diagnostic-standard-links.json")) + parser.add_argument("--output", type=Path, default=Path("dev/content/data/diagnostic-standard-links.json")) args = parser.parse_args() catalog = load_catalog(args.catalog) diff --git a/scripts/generate_diagnostic_standard_links.py b/dev/content/generate_diagnostic_standard_links.py similarity index 94% rename from scripts/generate_diagnostic_standard_links.py rename to dev/content/generate_diagnostic_standard_links.py index 91cd023..9e208fa 100644 --- a/scripts/generate_diagnostic_standard_links.py +++ b/dev/content/generate_diagnostic_standard_links.py @@ -6,32 +6,21 @@ from dataclasses import dataclass from pathlib import Path -try: - from scripts.autoformat_fixes import load_autoformat_catalog - from scripts.acc_diagnostics import build_link_reviews, load_catalog as load_acc_catalog, load_overrides - from scripts.diagnostic_articles import load_catalog - from scripts.diagnostic_standard_links import ( - load_reviews, - heading_anchor, - rewrite_diagnostic_page, - rewrite_standard_page, - ) -except ModuleNotFoundError: # Direct ``python scripts/...`` execution. - from autoformat_fixes import load_autoformat_catalog - from acc_diagnostics import build_link_reviews, load_catalog as load_acc_catalog, load_overrides - from diagnostic_articles import load_catalog - from diagnostic_standard_links import ( - load_reviews, - heading_anchor, - rewrite_diagnostic_page, - rewrite_standard_page, - ) +from dev.content.autoformat_fixes import load_autoformat_catalog +from dev.content.acc_diagnostics import build_link_reviews, load_catalog as load_acc_catalog, load_overrides +from dev.content.diagnostic_articles import load_catalog +from dev.content.diagnostic_standard_links import ( + load_reviews, + heading_anchor, + rewrite_diagnostic_page, + rewrite_standard_page, +) def load_all_reviews(root: Path) -> tuple: - reviewed = load_reviews(root / "data/diagnostic-standard-links.json") + reviewed = load_reviews(root / "dev/content/data/diagnostic-standard-links.json") acc_catalog = load_acc_catalog(root / "data/acc-diagnostics.json") - acc_overrides = load_overrides(root / "data/acc-standard-link-overrides.json") + acc_overrides = load_overrides(root / "dev/content/data/acc-standard-link-overrides.json") return reviewed + build_link_reviews(acc_catalog, acc_overrides) @@ -384,7 +373,7 @@ def render_family_index( def generate(root: Path, *, write: bool) -> tuple[int, int, int, bool, int]: catalog = load_catalog(root / "data/diagnostic-sources.json") reviews = load_all_reviews(root) - autoformat_catalog = load_autoformat_catalog(root / "data/autoformat-fixes.json") + autoformat_catalog = load_autoformat_catalog(root / "dev/content/data/autoformat-fixes.json") standard_pages = load_standard_pages(root / "docs/std") standard_titles = {standard: page.title for standard, page in standard_pages.items()} reviews_by_diagnostic: dict[str, list] = {} @@ -471,7 +460,7 @@ def main() -> int: mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--check", action="store_true") mode.add_argument("--write", action="store_true") - parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) args = parser.parse_args() ( diff --git a/scripts/standard_sources.py b/dev/content/standard_sources.py similarity index 95% rename from scripts/standard_sources.py rename to dev/content/standard_sources.py index 809c2db..0b9e8cd 100644 --- a/scripts/standard_sources.py +++ b/dev/content/standard_sources.py @@ -6,10 +6,7 @@ from pathlib import Path from urllib.parse import parse_qs, urlsplit -try: - from scripts.atomic_files import atomic_write_text -except ModuleNotFoundError: # Direct ``python scripts/...`` execution. - from atomic_files import atomic_write_text +from scripts.atomic_files import atomic_write_text STANDARD_RE = re.compile(r"std\d+") @@ -130,7 +127,7 @@ def main() -> int: parser.add_argument( "--registry", type=Path, - default=Path("data/standard-english-sources.json"), + default=Path("dev/content/data/standard-english-sources.json"), help="verified English source registry", ) args = parser.parse_args() diff --git a/scripts/sync_diagnostic_articles.py b/dev/content/sync_diagnostic_articles.py similarity index 95% rename from scripts/sync_diagnostic_articles.py rename to dev/content/sync_diagnostic_articles.py index c726ced..ae13754 100644 --- a/scripts/sync_diagnostic_articles.py +++ b/dev/content/sync_diagnostic_articles.py @@ -4,7 +4,7 @@ import argparse from pathlib import Path -from diagnostic_articles import SourceFamily, synchronize_articles +from dev.content.diagnostic_articles import SourceFamily, synchronize_articles SOURCE_FAMILIES = ( @@ -34,7 +34,7 @@ def build_parser() -> argparse.ArgumentParser: mode.add_argument("--write", action="store_true", help="write deterministic generated output") parser.add_argument("--bslls-checkout", type=Path, required=True) parser.add_argument("--v8-code-style-checkout", type=Path, required=True) - parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[2]) return parser diff --git a/dev/requirements-test.lock b/dev/requirements-test.lock new file mode 100644 index 0000000..c56db09 --- /dev/null +++ b/dev/requirements-test.lock @@ -0,0 +1,14 @@ +# Test-only union. Runtime/build versions and hashes remain in their own locks. +# Optional Starlette TestClient backend; no runtime HTTP-client replacement. +# PyPI release metadata verified 2026-09-15; install with --require-hashes. +-r ../requirements-build.lock +-r ../runtime/requirements-mcp.lock +httpx2==2.13.0 \ + --hash=sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a \ + --hash=sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44 +httpcore2==2.13.0 \ + --hash=sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e \ + --hash=sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db +truststore==0.10.4 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 \ + --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 diff --git a/docker-compose/.dockerignore b/docker-compose/.dockerignore deleted file mode 100644 index 4ba4e87..0000000 --- a/docker-compose/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -.github/ -.vscode/ -docker/ -site/ diff --git a/docker-compose/docker-compose.ngnix.yml b/docker-compose/docker-compose.ngnix.yml deleted file mode 100644 index 2bdf2fa..0000000 --- a/docker-compose/docker-compose.ngnix.yml +++ /dev/null @@ -1,20 +0,0 @@ -services: - zensical: - container_name: zensical - build: - context: .. - dockerfile: docker-compose/docker/Dockerfile - restart: "no" - volumes: - - ../:/docs - working_dir: /docs - command: build - - nginx: - image: nginx:alpine - restart: always - ports: - - "8000:8080" - volumes: - - ../site/:/usr/share/nginx/html/ - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:rw diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml deleted file mode 100644 index 9a1d1af..0000000 --- a/docker-compose/docker-compose.yml +++ /dev/null @@ -1,35 +0,0 @@ -services: - zensical: - container_name: zensical - build: - context: .. - dockerfile: docker-compose/docker/Dockerfile - restart: always - ports: - - "8000:8000" - volumes: - - ../:/docs - working_dir: /docs - command: serve --dev-addr=0.0.0.0:8000 - - v8std-mcp: - container_name: v8std-mcp - build: - context: .. - dockerfile: docker-compose/docker/Dockerfile - restart: always - ports: - - "127.0.0.1:8765:8765" - cap_drop: - - ALL - security_opt: - - no-new-privileges:true - volumes: - - ../:/docs - working_dir: /docs - environment: - V8STD_MCP_GENERATE_INDEX: auto - V8STD_MCP_HOST: 0.0.0.0 - V8STD_MCP_PORT: "8765" - V8STD_MCP_MAX_SNIPPET_CHARS: "${V8STD_MCP_MAX_SNIPPET_CHARS-4000}" - entrypoint: ["/opt/v8std/scripts/run_v8std_mcp.sh"] diff --git a/docker-compose/docker/Dockerfile b/docker-compose/docker/Dockerfile deleted file mode 100644 index 083162a..0000000 --- a/docker-compose/docker/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /opt/v8std - -RUN apt-get update \ - && apt-get install -y --no-install-recommends fonts-dejavu-core git \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt /opt/v8std/requirements.txt -COPY requirements-mcp.txt /opt/v8std/requirements-mcp.txt -COPY scripts /opt/v8std/scripts - -RUN chmod +x /opt/v8std/scripts/generate_social_cards.py \ - /opt/v8std/scripts/generate_search_vectors.py \ - /opt/v8std/scripts/install_zensical.sh \ - /opt/v8std/scripts/run_v8std_mcp.sh \ - /opt/v8std/scripts/v8std_mcp_server.py \ - /opt/v8std/scripts/zensical_docs.sh \ - && /opt/v8std/scripts/install_zensical.sh \ - && python -m pip install --upgrade -r /opt/v8std/requirements-mcp.txt - -WORKDIR /docs - -ENTRYPOINT ["/opt/v8std/scripts/zensical_docs.sh"] -CMD ["serve", "--dev-addr=0.0.0.0:8000"] diff --git a/docker-compose/docker/Dockerfile.dockerignore b/docker-compose/docker/Dockerfile.dockerignore deleted file mode 100644 index a3bb1bb..0000000 --- a/docker-compose/docker/Dockerfile.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -# Keep Docker build context minimal -** -!requirements.txt -!requirements-mcp.txt -!scripts/ -!scripts/** diff --git a/docker-compose/nginx/default.conf b/docker-compose/nginx/default.conf deleted file mode 100644 index cfd7f33..0000000 --- a/docker-compose/nginx/default.conf +++ /dev/null @@ -1,12 +0,0 @@ - -server { - listen 8080; - server_name localhost; - - location / { - root /usr/share/nginx/html; - index index.html; - } - - error_page 404 /404.html; -} diff --git a/docs/THIRD_PARTY_DIAGNOSTIC_ARTICLES.md b/docs/THIRD_PARTY_DIAGNOSTIC_ARTICLES.md index 32a4042..cd2fa6e 100644 --- a/docs/THIRD_PARTY_DIAGNOSTIC_ARTICLES.md +++ b/docs/THIRD_PARTY_DIAGNOSTIC_ARTICLES.md @@ -27,4 +27,4 @@ CC0 не заменяет и не изменяет лицензии содерж `diagnostic-source:start` и `diagnostic-source:end`. Обычная сборка сайта не обращается к сети. Обновление выполняется только из -явно переданных локальных checkout командой `scripts/sync_diagnostic_articles.py`. +явно переданных локальных checkout командой `dev/content/sync_diagnostic_articles.py`. diff --git a/docs/mcp.md b/docs/mcp.md index 22200c5..0ac0a49 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,214 +1,64 @@ --- -title: MCP v8std для ИИ-помощников +title: Стандарты 1С в ИИ-помощнике hide: - navigation - toc - feedback --- -# MCP v8std для ИИ-помощников +# Стандарты 1С в ИИ-помощнике -MCP v8std — это подключение к базе стандартов v8std.ru из редактора кода с ИИ. +Вы можете спросить у ИИ-помощника: «Почему в 1С не рекомендуют модальные окна?» +и попросить его найти ответ в стандартах v8std.ru со ссылкой на правило. +Для этого нужно один раз подключить к помощнику наш справочник. -Если вы раньше не работали с такими помощниками, представьте это как справочник, -к которому ИИ может обратиться сам. Вы задаете вопрос на русском языке, а -помощник при необходимости запрашивает у v8std нужный стандарт, диагностику или -связанный материал. +## Что такое ИИ и MCP -Сервис не меняет ваш код и не запускает проверки в проекте. Он только возвращает -материалы сайта: стандарты, описания диагностик и связи между ними. +ИИ-помощник — программа, с которой можно общаться обычным текстом. +Вы задаёте вопрос, она составляет ответ. Но помощник может ошибаться: +даже уверенное объяснение стоит сверять с источником. -## Зачем это нужно +MCP — способ подключать к ИИ-помощнику внешние источники и инструменты. +Через MCP v8std помощник может искать материалы этого сайта и читать найденные +стандарты. Вам достаточно задавать вопросы в привычном чате. -ИИ-помощник хорошо пишет текст, но без источника может ошибиться в правилах 1С -или сослаться на несуществующий стандарт. MCP дает ему доступ к актуальной базе -v8std.ru. +Например, вы просите объяснить предупреждение о модальном окне. Помощник +обращается к v8std, находит описание +bslls:UsingModalWindows +и связанный стандарт, затем объясняет правило и даёт ссылку для проверки. -После подключения помощник может: +## Как подключить -- найти стандарт по номеру, коду диагностики или обычной фразе; -- получить полный текст стандарта в удобном виде; -- объяснить предупреждение АПК, BSL Language Server или EDT; -- подобрать связанные стандарты по короткому фрагменту кода; -- перейти от диагностики к пункту стандарта, который объясняет причину ошибки. +Понадобится приложение с ИИ-помощником, которое поддерживает MCP. +В его настройках найдите раздел MCP или подключения внешних инструментов +и добавьте сервер: -## Как это работает +- **Название:** `v8std`. +- **Адрес:** `https://ai.v8std.ru/mcp`. -Вы подключаете к редактору адрес сервиса: +Это адрес для настроек помощника: отдельного чата при открытии в браузере нет. +Способ добавления зависит от приложения; если оно просит конфигурационный файл +вместо адреса, воспользуйтесь его инструкцией по подключению MCP. -```text -https://ai.v8std.ru/mcp -``` +После подключения убедитесь, что `v8std` включён. Если приложение попросит +разрешение обратиться к нему, подтвердите запрос, чтобы помощник мог найти материал. -Редактор сообщает ИИ-помощнику, что у него появились методы v8std. Когда вопрос -касается стандартов 1С, помощник выбирает подходящий метод, отправляет запрос в -v8std и получает ответ. +## Попробуйте первый вопрос -Транспорт сервиса рассчитан на coding agents: рабочие запросы идут через HTTP -POST. Необходимый для серверных уведомлений долгий SSE-канал не используется, -поэтому необязательный GET с `Accept: text/event-stream` отклоняется ответом -`405`; это не мешает обычным вызовам инструментов. +Скопируйте в чат: -Сервис комбинированный: v2 tools и MCP Resources обслуживаются одним endpoint -`/mcp` и одним runtime. `v8std_get_page` сохраняется для совместимости, а -Resources являются additive-профилем для клиентов, которые их поддерживают. +> Найди через v8std стандарт о модальных окнах в 1С. Объясни простыми словами, +> в чём проблема, и дай ссылку на стандарт. -Например: +Так же можно попросить найти правило по его номеру или объяснить предупреждение +из проверки кода. Названия внутренних инструментов и специальные команды +для этого знать не нужно. -1. Вы спрашиваете: "Почему нельзя использовать модальные окна?" -2. Помощник ищет стандарт через `v8std_search`. -3. Затем получает полный текст страницы через `v8std_get_page`. -4. В ответе он ссылается на найденный стандарт, а не отвечает по памяти. +Откройте ссылку из ответа и проверьте, подходит ли правило к вашей задаче. +Если помощник отвечает без источника, попросите его обратиться к v8std +и привести найденный материал. -## Методы v8std - -### `v8std_search` - -Ищет стандарты, диагностики и материалы сайта. - -Используйте, когда нужно найти правило по обычной фразе, номеру стандарта или -коду диагностики: - -- `модальные окна`; -- `std437`; -- acc:1245; -- bslls:UsingModalWindows; -- `локализация интерфейсных текстов`. - -### `v8std_get_page` - -Возвращает полный текст найденной страницы. - -Этот метод нужен, когда помощнику мало краткого результата поиска и нужно -прочитать сам стандарт, описание диагностики или связанный материал. - -### `v8std_get_related` - -Показывает связанные страницы. - -Например, от стандарта можно перейти к диагностике анализатора, а от диагностики -к стандарту, который объясняет нарушение. - -### `v8std_explain_snippet` - -Подбирает стандарты и вероятные диагностики по одной процедуре BSL или фрагменту SDBL. - -Известные признаки ищутся по всему принятому фрагменту, включая его конец. -Это подбор применимых правил, а не полноценный анализатор: отсутствие -рекомендаций не доказывает отсутствие ошибок. - -По умолчанию предел — 4000 символов. Для крупных процедур на собственном -сервере его можно увеличить до 32 000 через -`V8STD_MCP_MAX_SNIPPET_CHARS`; см. [локальную настройку](support.md#local-mcp). -Фактический предел указан в описании инструмента и `maxLength` его схемы -в `tools/list`. Превышение даёт ошибку без обрезки исходной процедуры: -уменьшите фрагмент, не повторяйте тот же запрос и не разбивайте весь модуль -автоматически на множество параллельных вызовов. - -Ответ содержит компактное начало текста (до 1000 символов), до 80 уникальных -токенов суммарной длиной до 4000 символов и найденные виды признаков без -повторов. `limit` ограничивает суммарное число рекомендаций в `diagnostics` -и `standards`: по умолчанию 10, максимум 50. `confidence` — эвристическая -оценка, не вероятность нарушения. Полные статьи запрашиваются отдельно -через `v8std_get_page` по нужным ID. - -Не отправляйте в публичный сервис закрытый код. Для такого сценария -используйте локальный запуск MCP. - -### `v8std_explain_diagnostics` - -Объясняет список диагностик АПК, BSL Language Server и EDT. - -Метод помогает после проверки проекта: помощник может сгруппировать найденные -предупреждения, показать описание диагностики и найти пункт стандарта, который -поясняет причину. - -## Примеры вопросов - -- "Найди стандарт про модальные окна в 1С". -- "Объясни диагностику bslls:UsingModalWindows". -- "Что означает acc:1245 и какой стандарт с ним связан?" -- "Какие стандарты относятся к локализации интерфейсных текстов?" -- "По этому фрагменту кода подбери применимые стандарты v8std". - -## Подключение - -Публичный сервис доступен без ключа: - -```text -https://ai.v8std.ru/mcp -``` - -### Cursor - -Создайте файл `.cursor/mcp.json` в проекте или `~/.cursor/mcp.json` для всех -проектов: - -```json -{ - "mcpServers": { - "v8std": { - "url": "https://ai.v8std.ru/mcp" - } - } -} -``` - -После сохранения откройте настройки MCP в Cursor и убедитесь, что сервер `v8std` -включен. - -### Claude Code - -Выполните команду в терминале: - -```bash -claude mcp add --transport http v8std https://ai.v8std.ru/mcp -``` - -После подключения можно открыть список MCP-серверов командой `/mcp` внутри -Claude Code. - -### Kiro - -Создайте файл `.kiro/settings/mcp.json` в проекте или `~/.kiro/settings/mcp.json` -для всех проектов: - -```json -{ - "mcpServers": { - "v8std": { - "url": "https://ai.v8std.ru/mcp" - } - } -} -``` - -Kiro загрузит сервер из этого файла и покажет методы v8std среди доступных -действий. - -### Antigravity - -Откройте панель агента, выберите меню `...`, затем `MCP Servers`, -`Manage MCP Servers` и `View raw config`. Добавьте сервер в `mcp_config.json`: - -```json -{ - "mcpServers": { - "v8std": { - "serverUrl": "https://ai.v8std.ru/mcp" - } - } -} -``` - -Если ваша версия Antigravity предлагает поле `url`, используйте тот же адрес -сервиса. - -## Что учитывать - -Публичный MCP видит текст запросов, которые отправляет ваш ИИ-помощник. Не -передавайте туда закрытые фрагменты кода, коммерческие данные и сведения из -непубличных проектов. - -Для закрытого кода, автономной работы или проверки локальных изменений запустите -MCP у себя. Инструкция находится на странице [Поддержка](support.md#local-mcp). +MCP v8std возвращает справочные материалы. Сам сервис не изменяет ваш проект +и не выполняет полную проверку кода. Он получает текст запросов, которые +отправляет помощник, поэтому не передавайте в публичный сервис закрытый код +и конфиденциальные данные. diff --git a/docs/support.md b/docs/support.md index 2f97de5..66952d1 100644 --- a/docs/support.md +++ b/docs/support.md @@ -20,7 +20,7 @@ hide: ### Как развернуть локально? -Для локального запуска без Docker требуется `Python 3.12`. Zensical устанавливается из PyPI в зафиксированной версии. +Для разработки сайта требуется `Python 3.12`. Zensical устанавливается из PyPI в зафиксированной версии. === ":fontawesome-brands-apple: mac" ```bash @@ -61,20 +61,6 @@ hide: ./scripts/zensical_docs.sh serve ``` -=== ":fontawesome-brands-docker: docker" - ```bash - git clone https://github.com/zeegin/v8std.git - cd v8std - - docker compose -f docker-compose/docker-compose.yml up --build - ``` - - Статическая сборка и проверка через `nginx`: - - ```bash - docker compose -f docker-compose/docker-compose.ngnix.yml up --build - ``` - Скрипт [`./scripts/install_zensical.sh`](https://github.com/zeegin/v8std/blob/main/scripts/install_zensical.sh) устанавливает зафиксированную версию Zensical из PyPI и Python-зависимости проекта из [`requirements.txt`](https://github.com/zeegin/v8std/blob/main/requirements.txt). Скрипт [`./scripts/zensical_docs.sh`](https://github.com/zeegin/v8std/blob/main/scripts/zensical_docs.sh) перед `serve` и `build` обновляет временные social cards и AI-артефакты. Пример production-сборки: @@ -83,144 +69,4 @@ hide: ./scripts/zensical_docs.sh build --strict ``` -Документация будет доступна по адресу `http://127.0.0.1:8000`. - -### Локальный MCP { #local-mcp } - -Локальный MCP нужен, если вы не хотите отправлять фрагменты закрытого кода в -публичный сервис, работаете без доступа к интернету или проверяете изменения -сайта до публикации. - -Самый простой запуск — через Docker: - -```bash -git clone https://github.com/zeegin/v8std.git -cd v8std - -docker compose -f docker-compose/docker-compose.yml up -d v8std-mcp -``` - -Локальный адрес MCP: - -```text -http://127.0.0.1:8765/mcp -``` - -Подключение к Codex: - -```bash -codex mcp add v8std-local --url http://127.0.0.1:8765/mcp -``` - -Подключение к Claude Code: - -```bash -claude mcp add --transport http v8std-local http://127.0.0.1:8765/mcp -``` - -Для Cursor и Kiro используйте тот же адрес в `mcp.json`: - -```json -{ - "mcpServers": { - "v8std-local": { - "url": "http://127.0.0.1:8765/mcp" - } - } -} -``` - -Для Antigravity используйте `mcp_config.json`: - -```json -{ - "mcpServers": { - "v8std-local": { - "serverUrl": "http://127.0.0.1:8765/mcp" - } - } -} -``` - -Контейнер читает файлы `docs/ai/pages.jsonl` и -`docs/ai/search-vectors.jsonl`. Если их нет, он сгенерирует индекс при старте. - -Без Docker MCP можно запустить так: - -```bash -python3.12 -m venv .venv -source .venv/bin/activate - -pip install -r requirements.txt -r requirements-mcp.txt -python scripts/generate_ai_artifacts.py -python scripts/generate_search_vectors.py -python scripts/v8std_mcp_server.py \ - --pages docs/ai/pages.jsonl \ - --vectors docs/ai/search-vectors.jsonl \ - --host 127.0.0.1 \ - --port 8765 -``` - -#### Крупные процедуры - -По умолчанию `v8std_explain_snippet` принимает до 4000 символов. На локальном -экземпляре можно задать целое значение от 4000 до 32 000 включительно: - -```bash -V8STD_MCP_MAX_SNIPPET_CHARS=32000 \ - docker compose -f docker-compose/docker-compose.yml up -d --build v8std-mcp -``` - -Для повторных запусков настройку можно сохранить в файле `.env` каталога -`docker-compose` и явно передавать `--env-file docker-compose/.env` команде -`docker compose`. Простого `docker compose restart` недостаточно для применения -новых переменных: выполните `up -d`, чтобы пересоздать контейнер с новой средой. - -При запуске Python или `scripts/run_v8std_mcp.sh` используется та же переменная. -У Python-сервера есть также аргумент `--max-snippet-chars 32000`: -CLI имеет приоритет над env, затем применяется default 4000. Пустое, дробное -или выходящее за диапазон значение останавливает запуск с ошибкой. Настройка -фиксируется до запуска сервера; после изменения нужен перезапуск. - -Проверьте эффективный предел через описание инструмента и -`tools/list.inputSchema.properties.snippet.maxLength`. Превышение не обрезает -код молча: передавайте одну релевантную процедуру в пределах своего экземпляра. -Лимит обычного `v8std_search` остаётся 500 символов. Настройка локального -экземпляра не меняет публичный сервис и не увеличивает число поисковых проходов -на вызов. - -Размер считается в символах декодированной Unicode-строки, не в токенах модели -или байтах HTTP. Если перед локальным сервером стоит свой reverse proxy, -настройте его byte-limit отдельно: для 32k строки с JSON-экранированием -Unicode и обычным конвертом запроса ориентир — 512 KiB. Дополнительные поля и -избыточные пробелы увеличивают тело запроса. Само расширение окна не означает -доказанную поддержку большего числа одновременных пользователей. - -Полезные команды: - -```bash -docker logs v8std-mcp -docker compose -f docker-compose/docker-compose.yml restart v8std-mcp -``` - -### Поиск и LLM-индексы - -Форматы запросов описаны на странице [Поиск по сайту](search-help.md). - -При `serve` и `build` автоматически генерируются статические AI-артефакты: - -- [`/llms.txt`](/llms.txt) — компактная карта сайта для LLM; -- [`/llms-full.txt`](/llms-full.txt) — очищенный полный Markdown-корпус; -- [`/ai/pages.jsonl`](/ai/pages.jsonl) — индекс страниц, алиасов, связей и очищенного Markdown; - -Чтобы исключить страницу из `/llms.txt` и `/llms-full.txt`, добавьте в её front matter: - -```yaml -llms: - ignore: true -``` - -Это не влияет на обычную сборку сайта, навигацию и поиск Zensical. Стандарты с -таким флагом остаются в `/ai/pages.jsonl`, чтобы MCP мог находить их по теме, -номеру или коду диагностики. Служебные страницы с этим флагом в MCP-индекс не -попадают. +Сайт будет доступен по адресу `http://127.0.0.1:8000`. diff --git a/overrides/main.html b/overrides/main.html index 664f04f..086e1e3 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -58,7 +58,9 @@ {% block extrahead %} {{ super() }} + {% if not config.extra.local_publication %} + {% endif %} {% include "partials/social_meta.html" ignore missing %} {% endblock %} diff --git a/requirements-build.lock b/requirements-build.lock new file mode 100644 index 0000000..e5292f2 --- /dev/null +++ b/requirements-build.lock @@ -0,0 +1,393 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-build.lock --constraint /dev/fd/11 --generate-hashes --universal --python-version 3.12 -o requirements-build.lock +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via + # -c /dev/fd/11 + # zensical +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via click +deepmerge==2.1.0 \ + --hash=sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f \ + --hash=sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75 + # via + # -c /dev/fd/11 + # zensical +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via + # -c /dev/fd/11 + # zensical +markdown==3.10.2 \ + --hash=sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950 \ + --hash=sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36 + # via + # -c /dev/fd/11 + # pymdown-extensions + # zensical +markdown-it-py==4.0.0 \ + --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ + --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 + # via + # -c /dev/fd/11 + # -r requirements-build.lock +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via + # -c /dev/fd/11 + # jinja2 +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via + # -c /dev/fd/11 + # markdown-it-py +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + # via + # -c /dev/fd/11 + # -r requirements-build.lock +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via + # -c /dev/fd/11 + # pygments-bsl + # zensical +pygments-bsl==1.1.0 \ + --hash=sha256:7c736ceff465a40704c94b76374746a34001d9755ac93d3b0cbe37e2d583acb5 \ + --hash=sha256:871114e9cbcac74e6583e367eac847bdb8c814267571b38336ca4accbd71539f + # via + # -c /dev/fd/11 + # -r requirements-build.lock +pymdown-extensions==11.0.1 \ + --hash=sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2 \ + --hash=sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0 + # via + # -c /dev/fd/11 + # zensical +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via + # -c /dev/fd/11 + # -r requirements-build.lock + # pymdown-extensions + # zensical +tomli==2.4.1 \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via + # -c /dev/fd/11 + # zensical +zensical==0.0.47 \ + --hash=sha256:1bd94937c48a2e42b5b65b32c5075849937f23cebccaf250d249efa27266e0be \ + --hash=sha256:2ee29ff819372eaab02ca0f14ac82e804332d898c57c56ffd4d89674f3e5ff71 \ + --hash=sha256:319cf370ecc6d87da69c935c5acd6e1959dd48954766e9f954319bdb0bec5d36 \ + --hash=sha256:324f783b22cd0deed0d0f3b69e28d5380b47238a1ef0f25913b88014af2bade2 \ + --hash=sha256:4162fb8b62f38e6d9b75688c1fb87e18a1cffef5eee51d2c18b79334b548e973 \ + --hash=sha256:4702605b991bece11494a9bb318d5ba7229f00e5adcc9e6010acd58e9719686b \ + --hash=sha256:59656bf604a8b03eede4ce1a847640bab1129ab86dec2e39e5bd4b808b1802d2 \ + --hash=sha256:6f6b2de477c45284201e92301f997415f45f78493bd20f479ea9a1c86bbbbcca \ + --hash=sha256:77f08ffcc3da9ca2f972330e501927aa7e8e445bfa7b758107edc645acda266e \ + --hash=sha256:81a13a8bacadedada4847eed4aaa4a3f4ef0e5b78dbb2a2022bc6fb7e7dc9464 \ + --hash=sha256:944a309be69b11daa8bba46c61fb74f32a98b637f3e7c11135e7cc2a5eccbe32 \ + --hash=sha256:97ed2b21aba5f788fc39d1597d00938d602b4d2724ed599a1dab7548fe4f0025 \ + --hash=sha256:cdc2d84f38da809a28402eda5f2b6dbb150e14427f296c5b521e0dfc6a2e8a39 + # via + # -c /dev/fd/11 + # -r requirements-build.lock diff --git a/requirements-mcp.txt b/requirements-mcp.txt deleted file mode 100644 index ce0e5f7..0000000 --- a/requirements-mcp.txt +++ /dev/null @@ -1,2 +0,0 @@ -mcp==1.27.0 -PyYAML diff --git a/requirements.txt b/requirements.txt index 8603312..309b4d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Pillow +markdown-it-py==4.0.0 PyYAML pygments-bsl diff --git a/runtime/__init__.py b/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/runtime/requirements-mcp.lock b/runtime/requirements-mcp.lock new file mode 100644 index 0000000..f627398 --- /dev/null +++ b/runtime/requirements-mcp.lock @@ -0,0 +1,685 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile runtime/requirements-mcp.txt --constraint /dev/fd/11 --generate-hashes --universal --python-version 3.12 -o runtime/requirements-mcp.lock +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via + # -c /dev/fd/11 + # pydantic +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via + # -c /dev/fd/11 + # httpx + # mcp + # sse-starlette + # starlette +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # -c /dev/fd/11 + # jsonschema + # referencing +certifi==2026.6.17 \ + --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ + --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db + # via + # -c /dev/fd/11 + # httpcore + # httpx +cffi==2.1.0 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ + --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ + --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ + --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ + --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ + --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ + --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ + --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ + --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ + --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ + --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ + --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ + --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ + --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ + --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ + --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ + --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ + --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ + --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ + --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ + --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ + --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ + --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ + --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ + --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ + --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ + --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ + --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ + --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ + --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ + --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ + --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ + --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ + --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ + --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ + --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ + --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ + --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ + --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ + --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ + --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ + --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ + --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ + --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ + --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ + --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ + --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ + --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ + --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ + --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ + --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ + --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ + --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ + --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ + --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ + --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ + --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ + --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ + --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ + --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ + --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ + --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ + --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ + --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ + --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ + --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ + --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ + --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ + --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ + --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ + --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ + --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ + --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ + --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ + --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ + --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ + --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ + --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ + --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ + --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ + --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ + --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ + --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ + --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ + --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ + --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ + --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ + --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ + --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ + --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ + --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ + --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ + --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ + --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ + --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ + --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ + --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ + --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ + --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ + --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f + # via + # -c /dev/fd/11 + # cryptography +click==8.4.2 ; sys_platform != 'emscripten' \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via + # -c /dev/fd/11 + # uvicorn +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via click +cryptography==49.0.0 \ + --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ + --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ + --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ + --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ + --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ + --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ + --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ + --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ + --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ + --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ + --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ + --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ + --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ + --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ + --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ + --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ + --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ + --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ + --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ + --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ + --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ + --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ + --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ + --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ + --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ + --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ + --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ + --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ + --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ + --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ + --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ + --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ + --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ + --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ + --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ + --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ + --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ + --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ + --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ + --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ + --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ + --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ + --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ + --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ + --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ + --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b + # via + # -c /dev/fd/11 + # pyjwt +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # -c /dev/fd/11 + # httpcore + # uvicorn +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via + # -c /dev/fd/11 + # httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via + # -c /dev/fd/11 + # mcp +httpx-sse==0.4.3 \ + --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ + --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d + # via + # -c /dev/fd/11 + # mcp +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # -c /dev/fd/11 + # anyio + # httpx +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via + # -c /dev/fd/11 + # mcp +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via + # -c /dev/fd/11 + # jsonschema +markdown-it-py==4.0.0 \ + --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ + --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 + # via + # -c /dev/fd/11 + # -r runtime/requirements-mcp.txt +mcp==1.27.0 \ + --hash=sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741 \ + --hash=sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83 + # via + # -c /dev/fd/11 + # -r runtime/requirements-mcp.txt +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via + # -c /dev/fd/11 + # markdown-it-py +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via + # -c /dev/fd/11 + # cffi +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via + # -c /dev/fd/11 + # mcp + # pydantic-settings +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via + # -c /dev/fd/11 + # pydantic +pydantic-settings==2.14.2 \ + --hash=sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440 \ + --hash=sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f + # via + # -c /dev/fd/11 + # mcp +pyjwt==2.13.0 \ + --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ + --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 + # via + # -c /dev/fd/11 + # mcp +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via + # -c /dev/fd/11 + # pydantic-settings +python-multipart==0.0.32 \ + --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + # via + # -c /dev/fd/11 + # mcp +pywin32==312 ; sys_platform == 'win32' \ + --hash=sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc \ + --hash=sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c \ + --hash=sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd \ + --hash=sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831 \ + --hash=sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed \ + --hash=sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db \ + --hash=sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950 \ + --hash=sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e \ + --hash=sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c \ + --hash=sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa \ + --hash=sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e \ + --hash=sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b \ + --hash=sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9 \ + --hash=sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47 \ + --hash=sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc \ + --hash=sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5 \ + --hash=sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9 \ + --hash=sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a \ + --hash=sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5 \ + --hash=sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b \ + --hash=sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb + # via mcp +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via + # -c /dev/fd/11 + # -r runtime/requirements-mcp.txt +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # -c /dev/fd/11 + # jsonschema + # jsonschema-specifications +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ + --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ + --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ + --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # -c /dev/fd/11 + # jsonschema + # referencing +sse-starlette==3.4.6 \ + --hash=sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6 \ + --hash=sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627 + # via + # -c /dev/fd/11 + # mcp +starlette==1.3.1 \ + --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ + --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 + # via + # -c /dev/fd/11 + # mcp + # sse-starlette +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # -c /dev/fd/11 + # anyio + # mcp + # pydantic + # pydantic-core + # referencing + # starlette + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via + # -c /dev/fd/11 + # mcp + # pydantic + # pydantic-settings +uvicorn==0.51.0 ; sys_platform != 'emscripten' \ + --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \ + --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0 + # via + # -c /dev/fd/11 + # mcp diff --git a/runtime/requirements-mcp.txt b/runtime/requirements-mcp.txt new file mode 100644 index 0000000..f97f404 --- /dev/null +++ b/runtime/requirements-mcp.txt @@ -0,0 +1,3 @@ +mcp==1.27.0 +markdown-it-py==4.0.0 +PyYAML diff --git a/runtime/v8std_mcp_hold.py b/runtime/v8std_mcp_hold.py new file mode 100644 index 0000000..c03c9a8 --- /dev/null +++ b/runtime/v8std_mcp_hold.py @@ -0,0 +1,135 @@ +"""Private, read-only host control mount; never an MCP method or source setting. + +One control directory and cache per managed container. Host atomically replaces +control.json; the runtime can read it but cannot write it. Only the coordinator +acknowledges a command, after cancelling preparation and selecting actual bytes. +Missing/malformed control fails closed (keeps serving the last accepted data, +does not refresh or acknowledge). No persisted Python objects cross this path. +""" +from pathlib import Path +import re +import time + +from runtime.v8std_mcp_snapshot_format import canonical_json, strict_json, validate_manifest, SnapshotError + +CONTROL_PATH = Path("/run/v8std-release/control.json") + + +class ReleaseControl: + def __init__(self, coordinator, path): + self.coordinator = coordinator + self.path = path + + def read(self): + from runtime.v8std_mcp_snapshots import _read_file, LoaderError + request = strict_json(_read_file(self.path, 65536)) + if (set(request) != {"schema_version", "token", "mode", "manifest"} + or type(request["schema_version"]) is not int or request["schema_version"] != 1 + or not isinstance(request["token"], str) + or not re.fullmatch("[a-f0-9]{32}", request["token"]) + or request["mode"] not in {"hold", "resume"}): + raise LoaderError("configuration") + if request["manifest"] is not None: + validate_manifest(canonical_json(request["manifest"])) + if request["mode"] != "hold": + raise LoaderError("configuration") + return request + + def pin(self, archive): + from runtime.v8std_mcp_snapshots import _file_lock + store = self.coordinator.store + deadline = time.monotonic() + 1 + with _file_lock(store.namespace / ".lock", deadline): + with _file_lock(store.cache_dir / ".volume.lock", deadline): + store._atomic_file(store.namespace / "runtime-pin.json", + canonical_json({"archive": archive}), deadline) + + def run(self): + from runtime.v8std_mcp_snapshots import LoaderError + owner = self.coordinator + initial = True + last = None + seen = None + next_refresh = 0 + failures = 0 + + def read_request(): + nonlocal seen + try: + return self.read() + except (LoaderError, SnapshotError, OSError): + # Recovery of the command file starts a fresh validation, even + # for the same token. Do not bypass build-failure backoff. + seen = None + raise + + while not owner._stop.is_set(): + try: + request = read_request() + changed = request != seen + if changed: + next_refresh = 0 + seen = request + if request["mode"] == "hold": + if request != last and time.monotonic() >= next_refresh: + manifest = request["manifest"] + if manifest is not None: + result, metadata = owner.store._run("refresh", owner.build, + CommandStop(self, request), selected_manifest=manifest) + if read_request() != request or owner._stop.is_set(): + continue + owner._accept(result, metadata, checked=False) + del result + elif not owner.status()["ready"]: + raise LoaderError("INDEX_NOT_READY") + # Capture uses the actual process identity, never state.json. + self.pin(owner.status()["archive_sha256"]) + with owner._lock: + owner._state["hold_token"] = request["token"] + owner._state["release_control_token"] = request["token"] + last = request + initial = False + else: + with owner._lock: + owner._state["hold_token"] = None + owner._state["release_control_token"] = request["token"] + last = request + if initial: + result, metadata = owner.store._run("cached", owner.build, CommandStop(self, request)) + if read_request() != request or owner._stop.is_set(): + continue + if metadata: + owner._accept(result, metadata, checked=False) + del result + initial = False + if time.monotonic() >= next_refresh: + result, metadata = owner.store._run("refresh", owner.build, + CommandStop(self, request), current_archive=owner._archive_sha256) + if read_request() != request or owner._stop.is_set(): + continue + owner._accept(result, metadata, checked=True) + del result + failures = 0 + next_refresh = (time.monotonic() + owner._delay(0) + if owner.refresh_seconds else float("inf")) + except (LoaderError, SnapshotError, OSError) as error: + failures += 1 + last = None # Revoked acknowledgement must be earned again. + with owner._lock: + owner._state["refresh_error_code"] = getattr(error, "code", "configuration") + # A stale acknowledgment is never proof of a new hold. + owner._state["hold_token"] = None + owner._state["release_control_token"] = None + next_refresh = time.monotonic() + owner._delay(failures) + owner._stop.wait(.1) + + +class CommandStop: + def __init__(self, control, request): + self.control, self.request = control, request + + def is_set(self): + try: + return self.control.coordinator._stop.is_set() or self.control.read() != self.request + except (ValueError, OSError): + return True diff --git a/scripts/v8std_mcp_index.py b/runtime/v8std_mcp_index.py similarity index 97% rename from scripts/v8std_mcp_index.py rename to runtime/v8std_mcp_index.py index 5aa8c73..7c234d5 100644 --- a/scripts/v8std_mcp_index.py +++ b/runtime/v8std_mcp_index.py @@ -16,8 +16,8 @@ from urllib.error import URLError from urllib.request import Request, urlopen -from v8std_retrieval_rules import RetrievalRules, normalize_text, tokenize -from v8std_search_features import ( +from scripts.v8std_retrieval_rules import RetrievalRules, normalize_text, tokenize +from scripts.v8std_search_features import ( canonical_search_terms, code_lookup_candidates, fuzzy_code_forms, @@ -47,8 +47,6 @@ RESOURCE_MAX_BYTES = { "pages.jsonl": 16 * 1024 * 1024, "search-vectors.jsonl": 32 * 1024 * 1024, - "llms.txt": 4 * 1024 * 1024, - "llms-full.txt": 16 * 1024 * 1024, } VECTOR_DIM = 256 MAX_VECTOR_ROWS = 100000 @@ -406,6 +404,27 @@ def __init__( self._bm25_body = BM25Corpus({}) self._metadata_terms_by_id: dict[str, set[str]] = {} self._missing_rule_targets: list[dict[str, str]] = [] + self._frozen = False + + @classmethod + def from_validated_bytes(cls, pages: bytes, vectors: bytes, *, max_snippet_chars: int = MAX_SNIPPET_CHARS): + """Build from a VerifiedSnapshot's canonical bytes without corpus I/O.""" + index = cls(max_snippet_chars=max_snippet_chars) + payload = pages.decode("utf-8") + entries, metadata = index._parse_vectors(vectors.decode("utf-8"), "snapshot") + index._replace_index(index._parse_pages(payload), "snapshot", payload, entries, metadata) + index._frozen = True + return index + + def __getstate__(self): + # Trusted spawn IPC only; this is not a persistent pickle cache format. + state = self.__dict__.copy() + del state["_lock"] + return state + + def __setstate__(self, state): + self.__dict__.update(state) + self._lock = threading.RLock() @property def max_snippet_chars(self) -> int: @@ -420,6 +439,8 @@ def vector_metadata(self) -> VectorMetadata | None: return self._vector_metadata def load(self, *, force_refresh: bool = False) -> None: + if self._frozen: + raise RuntimeError("index is frozen") with self._lock: payload, source = self._load_payload(force_refresh=force_refresh) pages = self._parse_pages(payload) @@ -427,7 +448,7 @@ def load(self, *, force_refresh: bool = False) -> None: self._replace_index(pages, source, payload, vectors, vector_metadata) def refresh_if_needed(self) -> None: - if self.pages_path is not None: + if self._frozen or self.pages_path is not None: return metadata = self._metadata if metadata is None or time.time() - metadata.loaded_at >= self.refresh_seconds: @@ -754,32 +775,8 @@ def resolve(self, id_or_alias_or_url: str) -> dict[str, Any] | None: with self._lock: return self._pages_by_key.get(key) - def read_resource_text(self, resource_name: str) -> str: - self.refresh_if_needed() - if resource_name == "pages.jsonl" and self.pages_path is not None: - return self.pages_path.read_text(encoding="utf-8") - - if self.pages_path is not None: - docs_dir = self.pages_path.parent.parent - local = { - "llms.txt": docs_dir / "llms.txt", - "llms-full.txt": docs_dir / "llms-full.txt", - "pages.jsonl": self.pages_path, - }.get(resource_name) - if local and local.is_file(): - return local.read_text(encoding="utf-8") - - remote_path = { - "llms.txt": "https://v8std.ru/llms.txt", - "llms-full.txt": "https://v8std.ru/llms-full.txt", - "pages.jsonl": self.index_url, - }.get(resource_name) - if not remote_path: - raise ValueError(f"unknown resource: {resource_name}") - payload, _source = self._load_remote_resource(resource_name, remote_path) - return payload - - def _validate_types(self, types: list[str] | None) -> set[str] | None: + @staticmethod + def _validate_types(types: list[str] | None) -> set[str] | None: values = require_string_list(types, "types", MAX_ENUM_CHARS) if values is None: return None @@ -787,13 +784,15 @@ def _validate_types(self, types: list[str] | None) -> set[str] | None: raise ValueError("invalid page type") return set(values) - def _validate_mode(self, mode: str) -> str: + @staticmethod + def _validate_mode(mode: str) -> str: mode = require_text(mode, "mode", MAX_ENUM_CHARS) if mode not in VALID_MODES: raise ValueError("invalid search mode") return mode - def _validate_relations(self, relations: list[str] | None) -> set[str] | None: + @staticmethod + def _validate_relations(relations: list[str] | None) -> set[str] | None: values = require_string_list(relations, "relations", MAX_ENUM_CHARS) if values is None: return None @@ -1113,6 +1112,9 @@ def _load_vectors(self, *, force_refresh: bool) -> tuple[list[VectorEntry], Vect except IndexLoadError: return [], None + return self._parse_vectors(payload, source) + + def _parse_vectors(self, payload: str, source: str) -> tuple[list[VectorEntry], VectorMetadata | None]: vectors: list[VectorEntry] = [] model = "" dim = 0 diff --git a/runtime/v8std_mcp_presentation.py b/runtime/v8std_mcp_presentation.py new file mode 100644 index 0000000..6bef8e9 --- /dev/null +++ b/runtime/v8std_mcp_presentation.py @@ -0,0 +1,479 @@ +"""Source-preserving CommonMark link presentation shared by runtime/publisher. + +The pinned parser decides syntax; instrumentation retains source offsets through +normalization and container indentation. Only accepted destination spans change. +Neither canonical retrieval input nor non-link prose is rendered/reserialized. +""" +from __future__ import annotations +from array import array +from bisect import bisect_right +from difflib import SequenceMatcher +import html +from html.parser import HTMLParser +import re +from types import SimpleNamespace +from urllib.parse import unquote, urljoin, urlsplit, urlunsplit + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock, reference +from markdown_it.rules_inline import link, image, autolink, html_inline, text as inline_text + + +class PresentationError(ValueError): + """Bounded publisher error, with no source URL or document payload.""" + code = "unresolved_internal_link" + + def __init__(self): + super().__init__(self.code) + + +# Explicit published auxiliaries, not a prefix allowlist or searchable pages. +AUXILIARY_PATHS = frozenset({"llms.txt", "llms-full.txt", "ai/pages.jsonl", + "LICENSES/", "LICENSES/LGPL-3.0.txt", + "LICENSES/GPL-3.0.txt", "LICENSES/EPL-2.0.txt"}) + +_ATTR = re.compile(r'''([^\s=<>/]+)(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))''') +_FIELD = re.compile(r"(?:Markdown URL|URL|HTML):[ \t]+(https?://[^\s<>]+)") +_PROVENANCE = re.compile(r"^External sources:[^\n]*(?:\n- [^\n]*)*", re.M) +_PROTECTED_TAGS = {"code", "pre", "script", "style"} + + +class LinkCatalog: + def __init__(self, canonical_site_url, site_url, page_paths): + self.canonical = canonical_site_url + self.site = site_url + self.pages = page_paths + self.paths = set(AUXILIARY_PATHS) + for page in page_paths.values(): + self.paths.update((page["site_path"], page["markdown_path"])) + + def link(self, value, *, context=None, validate=False): + # Callers supply the value interpreted in its own Markdown/HTML/JSON + # context. Applying a second universal unescape corrupts URL suffixes. + if not value or value.startswith("#"): + return value + base = urlsplit(self.canonical) + target = urlsplit(urljoin(context or self.canonical, value)) + if (target.scheme, target.netloc) != (base.scheme, base.netloc): + return value + path = target.path + # Encoded separators/dot traversal must not turn a known path into an + # alternate route. Relative ../ links resolve normally inside the base. + unsafe = re.search(r"%(?:2f|5c|25|2e)", path, re.I) or "\\" in path + relative = unquote(path[len(base.path):]) if path.startswith(base.path) else None + if unsafe or relative not in self.paths: + if validate: + raise PresentationError() + return value + result = urlsplit(urljoin(self.site, relative)) + return urlunsplit((result.scheme, result.netloc, result.path, target.query, target.fragment)) + + def lookup(self, value): + """Translate configured local page URLs to the original lookup key.""" + target, base = urlsplit(value), urlsplit(self.site) + if (target.scheme, target.netloc) == (base.scheme, base.netloc) and target.path.startswith(base.path): + path = target.path[len(base.path):] + if path in self.paths - AUXILIARY_PATHS: + return urljoin(self.canonical, path) + return value + + +class _MappedText(str): + """Original offsets survive parser slicing and container indentation. + + Contiguous slices use ranges; arrays join lines across removed prefixes. + Virtual indentation has offset -1. This is source mapping, not a grammar. + """ + def __new__(cls, value, offsets=None): + obj = super().__new__(cls, value) + obj.offsets = range(len(value)) if offsets is None else offsets + return obj + + def __getitem__(self, key): + value = super().__getitem__(key) + return _MappedText(value, self.offsets[key]) if isinstance(key, slice) else value + + def __add__(self, other): + return self.combine((self, other)) + + def __radd__(self, other): + return self.combine((other, self)) + + @classmethod + def combine(cls, parts): + parts = list(parts) + if len(parts) == 1: + return parts[0] + offsets = array("i") + for part in parts: + offsets.extend(part.offsets if isinstance(part, cls) else [-1] * len(part)) + return cls("".join(parts), offsets) + + def strip(self, chars=None): + start = len(self) - len(str.lstrip(self, chars)) + end = len(str.rstrip(self, chars)) + return self[start:max(start, end)] + + def span(self, start=0, end=None): + end = len(self) if end is None else end + if end <= start: + return None + offsets = self.offsets[start:end] + # Never delete a removed container prefix with a destination edit. + if offsets[0] < 0 or offsets[-1] - offsets[0] != len(offsets) - 1: + return None + return offsets[0], offsets[-1] + 1 + + +def _normalize(state): + source = state.src + parts, previous = [], 0 + for match in re.finditer(r"\r\n?|\x00", source): + parts.append(_MappedText(source[previous:match.start()], range(previous, match.start()))) + parts.append(_MappedText("\ufffd" if match[0] == "\x00" else "\n", [match.start()])) + previous = match.end() + parts.append(_MappedText(source[previous:], range(previous, len(source)))) + state.src = _MappedText.combine(parts) + + +def _block(state): + block = StateBlock(state.src, state.md, state.env, state.tokens) + original = block.getLines + + def get_lines(begin, end, indent, keep_last): + parts = [] + for line in range(begin, end): + keep = line + 1 < end or keep_last + rendered = original(line, line + 1, indent, keep) + raw = block.src[block.bMarks[line]:block.eMarks[line] + int(keep)] + if raw.endswith(rendered): + parts.append(raw[len(raw) - len(rendered):]) + continue + common = 0 + while common < min(len(raw), len(rendered)) and raw[-common - 1] == rendered[-common - 1]: + common += 1 + parts.append(_MappedText.combine(( + rendered[:len(rendered) - common], + raw[len(raw) - common:] if common else _MappedText(""), + ))) + return _MappedText.combine(parts) + + block.getLines = get_lines + try: + state.md.block.tokenize(block, block.line, block.lineMax) + finally: + # The adapter closes over StateBlock; detach it even on parser failure + # so source maps and token trees do not wait for cyclic GC. + del block.getLines + + +class _HTMLNodes(HTMLParser): + """HTML decides attributes; edits retain their original quoting context.""" + def __init__(self, source, nodes, tags): + super().__init__(convert_charrefs=False) + self.source, self.nodes, self.tags = source, nodes, tags + self.lines = [0] + [match.end() for match in re.finditer("\n", source)] + + def source_offset(self): + line, column = self.getpos() + return self.lines[line - 1] + column + + def handle_starttag(self, tag, attrs): + start = self.source_offset() + raw = self.get_starttag_text() + # Only the tag's start anchors protection. A multiline opening tag + # may cross removed quote/list prefixes without making its code live. + span = self.source.span(start, start + 1) + if span: + self.tags.append((*span, tag, False)) + allowed = {key for key, _ in attrs if key in {"href", "src", "poster", "action", "cite"}} + for match in _ATTR.finditer(raw): + if match[1].lower() not in allowed: + continue + group = next(n for n in (3, 4, 5) if match[n] is not None) + raw_value = self.source[start + match.start(group):start + match.end(group)] + span = raw_value.span() + value = html.unescape(match[group]) + if not span and raw_value and raw_value.offsets[0] >= 0 and raw_value.offsets[-1] >= 0: + # Multiline quoted destinations can cross container prefixes. + # Retain their source map for edit projection, not a broad cut. + span = raw_value.offsets[0], raw_value.offsets[-1] + 1 + value = _HTMLValue(value, raw_value) + if span: + self.nodes.append((*span, value, "html_unquoted" if group == 5 else "html")) + + def handle_endtag(self, tag): + start = self.source_offset() + span = self.source.span(start, start + 1) + if span: + self.tags.append((*span, tag, True)) + + +class _HTMLValue(str): + def __new__(cls, value, raw): + obj = super().__new__(cls, value) + obj.raw = raw + return obj + + def edits(self, replacement): + """Project destination-only edits around structural source gaps. + + URL parsing ignores literal CR/LF. Keep those physical line breaks and + removed container markers so rebasing never joins Markdown containers. + Entity escaping still follows the attribute's actual quoting context. + """ + for operation, start, end, new_start, new_end in SequenceMatcher( + None, str(self.raw), replacement).get_opcodes(): + if operation == "equal": + continue + value = replacement[new_start:new_end] + if start == end: + offset = self.raw.offsets[start] if start < len(self.raw) else self.raw.offsets[-1] + 1 + if offset >= 0: + yield offset, offset, value + continue + spans = [] + for position in range(start, end): + offset = self.raw.offsets[position] + if offset < 0 or self.raw[position] == "\n": + continue + if spans and spans[-1][1] == offset: + spans[-1] = (spans[-1][0], offset + 1) + else: + spans.append((offset, offset + 1)) + for number, (begin, finish) in enumerate(spans): + yield begin, finish, value if number == 0 else "" + + +class _FirstHTMLTag(HTMLParser): + """Recognize HTML attributes also accepted by HTML outside CommonMark's + stricter inline-tag grammar (notably unquoted query '=' characters). + """ + def handle_starttag(self, tag, attrs): + if self.getpos() == (1, 0): + self.first = self.get_starttag_text() + raise _TagFinished + + def handle_data(self, data): + raise _TagFinished + + +class _TagFinished(Exception): + pass + + +def _merged_spans(spans): + merged = [] + for start, end in sorted(spans): + if merged and start <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(end, merged[-1][1])) + else: + merged.append((start, end)) + return merged + + +def _covered(offset, spans): + position = bisect_right(spans, (offset, float("inf"))) - 1 + return position >= 0 and offset < spans[position][1] + + +def _html_inline(state, silent): + if html_inline(state, silent): + return True + if not re.match(r"<[A-Za-z]", state.src[state.pos:state.pos + 2]): + return False + parser = _FirstHTMLTag() + parser.first = None + try: + parser.feed(str(state.src[state.pos:])) + except _TagFinished: + pass + if parser.first is None: + return False + end = state.pos + len(parser.first) + if not silent: + token = state.push("html_inline", "", 0) + token.content = state.src[state.pos:end] + state.pos = end + return True + + +def _link_nodes(source, generated_fields): + """Instrument successful parser rules, never error-recovery guesses.""" + md = MarkdownIt("commonmark") + nodes, tags, text_spans, frames = [], [], [], [] + md.core.ruler.at("normalize", _normalize) + md.core.ruler.at("block", _block) + helpers = md.helpers + md.helpers = SimpleNamespace(parseLinkLabel=helpers.parseLinkLabel, + parseLinkTitle=helpers.parseLinkTitle) + + def destination(src, pos, maximum): + result = helpers.parseLinkDestination(src, pos, maximum) + if result.ok and frames and isinstance(src, _MappedText): + angle = src[pos:pos + 1] == "<" + span = src.span(pos + int(angle), result.pos - int(angle)) + if span: + frames[-1].append((*span, result.str, "angle" if angle else "markdown")) + return result + + md.helpers.parseLinkDestination = destination + + def ref_rule(state, begin, end, silent): + frames.append([]) + accepted = reference(state, begin, end, silent) + candidates = frames.pop() + if accepted and not silent: + nodes.extend(candidates) + return accepted + + md.block.ruler.at("reference", ref_rule) + + def image_rule(state, silent): + # Image labels are parsed into alt children, not document-level HTML + # or links. Keep the parser's tokens, but discard their collector side + # effects before wrap() records the image's own destination. + node_count, tag_count, text_count = len(nodes), len(tags), len(text_spans) + try: + return image(state, silent) + finally: + del nodes[node_count:] + del tags[tag_count:] + del text_spans[text_count:] + + def wrap(rule, kind): + def run(state, silent): + start, count = state.pos, len(state.tokens) + frames.append([]) + accepted = rule(state, silent) + candidates = frames.pop() + if not accepted or silent: + return accepted + src = state.src + emitted = state.tokens[count:] + if kind in {"link", "image"}: + urls = {token.attrGet("href") or token.attrGet("src") for token in emitted + if token.type in {"link_open", "image"}} + end_offset = src.offsets[state.pos - 1] + 1 + nodes.extend(candidate for candidate in candidates + if candidate[1] <= end_offset and state.md.normalizeLink(candidate[2]) in urls) + elif kind == "autolink": + span = src.span(start + 1, state.pos - 1) + if span: + nodes.append((*span, str(src[start + 1:state.pos - 1]), "autolink")) + elif kind == "html_inline": + _HTMLNodes(src[start:state.pos], nodes, tags).feed(str(src[start:state.pos])) + elif generated_fields: + span = src.span(start, state.pos) + if span: + text_spans.append(span) + return accepted + return run + + for name, rule in (("link", link), ("image", image_rule), ("autolink", autolink), + ("html_inline", _html_inline), ("text", inline_text)): + md.inline.ruler.at(name, wrap(rule, name)) + tokens = md.parse(source) + for token in tokens: + if token.type == "html_block": + _HTMLNodes(token.content, nodes, tags).feed(str(token.content)) + protected, opened = [], [] + for start, end, tag, closing in sorted(tags): + if tag not in _PROTECTED_TAGS: + continue + if not closing: + opened.append(start) + elif opened: + protected.append((opened.pop(), end)) + protected.extend((start, len(source)) for start in opened) + if generated_fields: + protected.extend(match.span() for match in _PROVENANCE.finditer(source)) + text_spans = _merged_spans(text_spans) + for match in _FIELD.finditer(source): + prefix = source[source.rfind("\n", 0, match.start()) + 1:match.start()] + if prefix and not (match[0].startswith("HTML:") and prefix.startswith("- [")): + continue + if not _covered(match.start(), text_spans): + continue + start, end = match.span(1) + if source[end - 1] == ".": + end -= 1 + nodes.append((start, end, source[start:end], "context" if match[0].startswith("URL:") else "field")) + protected = _merged_spans(protected) + return [node for node in sorted(set(nodes)) if not _covered(node[0], protected)], md + + +def _escape_destination(value, kind, md): + if kind.startswith("html"): + value = html.escape(value, quote=True) + if kind == "html_unquoted": + value = re.sub(r"[\s=\x60]", lambda m: "&#" + str(ord(m[0])) + ";", value) + return value + if kind in {"field", "context"}: + return value + value = md.normalizeLink(value) + if kind != "autolink": + value = value.replace("&", "&") + if kind == "markdown": + value = re.sub(r"[\\()]", lambda m: "\\" + m[0], value) + return value + + +def _markdown(text, catalog, *, context=None, generated_fields=False, validate=False): + nodes, md = _link_nodes(text, generated_fields) + parts, previous = [], 0 + for start, end, value, kind in nodes: + if kind == "context": + context = value + replacement = catalog.link(value, context=context, validate=validate) + if replacement == value: + continue + if start < previous: + continue + escaped = _escape_destination(replacement, kind, md) + edits = value.edits(escaped) if isinstance(value, _HTMLValue) else [(start, end, escaped)] + for begin, finish, content in edits: + parts.extend((text[previous:begin], content)) + previous = finish + parts.append(text[previous:]) + return "".join(parts) + + +def present_markdown(text, *, canonical_site_url, site_url, page_paths, + context=None, generated_fields=False): + return _markdown(text, LinkCatalog(canonical_site_url, site_url, page_paths), + context=context, generated_fields=generated_fields) + + +def validate_links(text, *, canonical_site_url, page_paths, context=None, generated_fields=False): + _markdown(text, LinkCatalog(canonical_site_url, canonical_site_url, page_paths), + context=context, generated_fields=generated_fields, validate=True) + + +def present_result(value, *, canonical_site_url, site_url, page_paths): + catalog = LinkCatalog(canonical_site_url, site_url, page_paths) + + def visit(item, context=None): + if isinstance(item, list): + return [visit(child, context) for child in item] + if not isinstance(item, dict): + return item + page = page_paths.get(item.get("id")) + if page: + context = urljoin(canonical_site_url, page["site_path"]) + result = {} + for key, child in item.items(): + if key in {"url", "markdown_url"} and isinstance(child, str): + path_key = "site_path" if key == "url" else "markdown_path" + if page: + base, suffix = urlsplit(urljoin(site_url, page[path_key])), urlsplit(child) + result[key] = urlunsplit((base.scheme, base.netloc, base.path, suffix.query, suffix.fragment)) + else: + result[key] = catalog.link(child, context=context) + elif key == "body_markdown" and isinstance(child, str): + result[key] = _markdown(child, catalog, context=context) + else: + result[key] = visit(child, context) + return result + + return visit(value) diff --git a/runtime/v8std_mcp_runtime.py b/runtime/v8std_mcp_runtime.py new file mode 100644 index 0000000..aa1be7e --- /dev/null +++ b/runtime/v8std_mcp_runtime.py @@ -0,0 +1,132 @@ +"""One immutable index generation per complete data call.""" +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from pathlib import Path + +from runtime.v8std_mcp_index import ( + V8StdIndex, DEFAULT_CACHE_DIR, MAX_BODY_CHARS, MAX_QUERY_CHARS, MAX_ID_OR_ALIAS_CHARS, + MAX_SNIPPET_CHARS, MAX_ENUM_CHARS, MAX_DIAGNOSTIC_CODES, MAX_DIAGNOSTIC_CODE_CHARS, + clamp_body_limit, clamp_limit, require_text, trim_body, validate_max_snippet_chars, +) +from runtime.v8std_mcp_presentation import LinkCatalog, present_result +from runtime.v8std_mcp_snapshot_format import DEFAULT_SITE_URL, VerifiedSnapshot, normalize_site_url +from runtime.v8std_mcp_snapshots import LoaderError, SnapshotCoordinator, SnapshotStore +from runtime.v8std_mcp_hold import CONTROL_PATH + + +@dataclass(frozen=True) +class IndexGeneration: + corpus_id: str + index: V8StdIndex + canonical_site_url: str + page_paths: dict + + +def build_generation(snapshot: VerifiedSnapshot, *, max_snippet_chars: int, + site_url: str | None = None) -> IndexGeneration: + index = V8StdIndex.from_validated_bytes(snapshot.files["pages.jsonl"], + snapshot.files["search-vectors.jsonl"], max_snippet_chars=max_snippet_chars) + paths = {page["id"]: {key: page[key] for key in ("site_path", "markdown_path")} + for page in index._pages} + canonical = snapshot.metadata["canonical_site_url"] + return IndexGeneration(snapshot.metadata["corpus_id"], index, canonical, paths) + + +class SnapshotIndex: + def __init__(self, *, site_url: str = DEFAULT_SITE_URL, cache_dir: Path = DEFAULT_CACHE_DIR, + refresh_seconds: int = 3600, max_snippet_chars: int = MAX_SNIPPET_CHARS, + runtime_sha: str | None = None, release_control: Path | None = None): + self.site_url = normalize_site_url(site_url) + self._max_snippet_chars = validate_max_snippet_chars(max_snippet_chars) + self.runtime_sha = runtime_sha if runtime_sha and len(runtime_sha) == 40 and all( + char in "0123456789abcdef" for char in runtime_sha) else None + self.coordinator = SnapshotCoordinator(SnapshotStore(self.site_url, cache_dir), + partial(build_generation, max_snippet_chars=max_snippet_chars, site_url=self.site_url), + refresh_seconds=refresh_seconds, release_control=(release_control if release_control is not None + else CONTROL_PATH if CONTROL_PATH.parent.exists() else None)) + + @property + def max_snippet_chars(self): + return self._max_snippet_chars + + def start(self): + self.coordinator.start() + + def close(self): + self.coordinator.close() + + def status(self): + state = self.coordinator.status() + counts = {"row_count": 0, "semantic_enabled": False} + if state["ready"]: + generation = self.coordinator.current() + if generation.corpus_id == state["corpus_id"]: + counts = {"row_count": generation.index.metadata.row_count, + "semantic_enabled": generation.index.vector_metadata is not None} + # No source paths or unbounded missing-target list. + return {"ok": state["ready"], **counts, "runtime_sha": self.runtime_sha, **state} + + def _current(self): + try: + return self.coordinator.current() + except LoaderError as error: + if error.code == "INDEX_NOT_READY": + raise ValueError("INDEX_NOT_READY: retry later") from None + raise + + def _present(self, generation, result): + return present_result(result, canonical_site_url=generation.canonical_site_url, + site_url=self.site_url, page_paths=generation.page_paths) + + def _lookup(self, generation, value): + return LinkCatalog(generation.canonical_site_url, self.site_url, generation.page_paths).lookup(value) + + def search(self, query, *, types=None, mode="hybrid", limit=None): + require_text(query, "query", MAX_QUERY_CHARS) + clamp_limit(limit) + V8StdIndex._validate_types(types) + V8StdIndex._validate_mode(mode) + generation = self._current() + return self._present(generation, generation.index.search(query, types=types, mode=mode, limit=limit)) + + def page(self, id_or_alias_or_url, *, body_limit=MAX_BODY_CHARS): + require_text(id_or_alias_or_url, "id_or_alias_or_url", MAX_ID_OR_ALIAS_CHARS) + body_limit = clamp_body_limit(body_limit) + generation = self._current() + lookup = self._lookup(generation, id_or_alias_or_url) + page = generation.index.resolve(lookup) + if page is not None: + # Parse the full link before trimming. A longer local prefix cannot + # enlarge the inherited body budget or leave a cut public link. + presented = self._present(generation, page) + return {"found": True, "page": trim_body(presented, body_limit), "candidates": []} + return self._present(generation, generation.index.page(lookup, body_limit=body_limit)) + + def related(self, id_or_alias_or_url, *, relations=None, limit=None): + require_text(id_or_alias_or_url, "id_or_alias_or_url", MAX_ID_OR_ALIAS_CHARS) + clamp_limit(limit) + V8StdIndex._validate_relations(relations) + generation = self._current() + return self._present(generation, generation.index.related(self._lookup(generation, id_or_alias_or_url), + relations=relations, limit=limit)) + + def explain_snippet(self, snippet, *, language="auto", limit=None): + require_text(snippet, "snippet", self.max_snippet_chars) + require_text(language, "language", MAX_ENUM_CHARS) + if language not in {"auto", "bsl", "sdbl"}: + raise ValueError("language must be one of: auto, bsl, sdbl") + clamp_limit(limit) + generation = self._current() + return self._present(generation, generation.index.explain_snippet(snippet, language=language, limit=limit)) + + def explain_diagnostics(self, codes): + if not isinstance(codes, list): + raise ValueError("codes must be a list") + if len(codes) > MAX_DIAGNOSTIC_CODES: + raise ValueError(f"codes list is too long: max {MAX_DIAGNOSTIC_CODES}") + for code in codes: + require_text(code, "diagnostic code", MAX_DIAGNOSTIC_CODE_CHARS) + generation = self._current() + return self._present(generation, generation.index.explain_diagnostics(codes)) diff --git a/scripts/v8std_mcp_server.py b/runtime/v8std_mcp_server.py similarity index 70% rename from scripts/v8std_mcp_server.py rename to runtime/v8std_mcp_server.py index 058aa53..31070b0 100644 --- a/scripts/v8std_mcp_server.py +++ b/runtime/v8std_mcp_server.py @@ -8,18 +8,22 @@ import logging import os import sys +from contextlib import asynccontextmanager from contextvars import ContextVar from datetime import datetime, timezone from pathlib import Path from typing import Annotated, Any +import anyio + +import mcp.types as mcp_types from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings from pydantic import Field from starlette.requests import Request from starlette.responses import JSONResponse, Response -from v8std_mcp_index import ( +from runtime.v8std_mcp_index import ( DEFAULT_CACHE_DIR, DEFAULT_INDEX_URL, DEFAULT_REFRESH_SECONDS, @@ -30,6 +34,8 @@ V8StdIndex, validate_max_snippet_chars, ) +from runtime.v8std_mcp_runtime import SnapshotIndex +from runtime.v8std_mcp_snapshot_format import DEFAULT_SITE_URL, SnapshotError, normalize_site_url MCP_SELF_DOC_MESSAGE = "This is a MCP Streamable HTTP endpoint" @@ -52,7 +58,7 @@ "This stateless MCP endpoint does not provide an unsolicited SSE stream; " "send JSON-RPC requests with POST." ) -MCP_API_PROFILES = ["legacy-tools", "resources"] +MCP_API_PROFILES = ["legacy-tools"] MAX_USAGE_TEXT_CHARS = 240 MAX_USAGE_RESULTS = 50 MAX_USAGE_CODES = 500 @@ -490,17 +496,47 @@ async def _send_response( await send({"type": "http.response.body", "body": body}) -def install_self_documenting_mcp_app(server: FastMCP, *, mcp_path: str) -> None: +def install_self_documenting_mcp_app(server: FastMCP, *, mcp_path: str, index=None) -> None: original_streamable_http_app = server.streamable_http_app def streamable_http_app_with_self_documentation(): - return SelfDocumentingMcpApp(original_streamable_http_app(), mcp_path=mcp_path) + app = original_streamable_http_app() + if isinstance(index, SnapshotIndex): + original_lifespan = app.router.lifespan_context + + @asynccontextmanager + async def lifespan(app): + index.start() + try: + async with original_lifespan(app) as state: + yield state + finally: + with anyio.CancelScope(shield=True): + await anyio.to_thread.run_sync(index.close) + + app.router.lifespan_context = lifespan + return SelfDocumentingMcpApp(app, mcp_path=mcp_path) server.streamable_http_app = streamable_http_app_with_self_documentation # type: ignore[method-assign] + + +def _disable_resource_handlers(server: FastMCP) -> None: + # SDK 1.27 registers these even without resource decorators. Removing the + # handlers also removes the capability and uses normal Method not found dispatch. + for request_type in ( + mcp_types.ListResourcesRequest, + mcp_types.ListResourceTemplatesRequest, + mcp_types.ReadResourceRequest, + mcp_types.SubscribeRequest, + mcp_types.UnsubscribeRequest, + ): + server._mcp_server.request_handlers.pop(request_type, None) + + def build_server( - index: V8StdIndex, + index: V8StdIndex | SnapshotIndex, *, host: str, port: int, @@ -536,24 +572,24 @@ def build_server( allowed_origins=allowed_origins, ), ) + _disable_resource_handlers(server) @server.tool( name="v8std_search", description=( - "Use this when the user asks an arbitrary phrase question or topic search " - "over v8std.ru standards, diagnostics, patterns, or service pages, including " - "natural Russian phrases, standard ids such as std437, and diagnostic names " - "when no exact list is available. Do not use this first for code snippets " - "or diagnostic-code lists; prefer v8std_explain_snippet or " - "v8std_explain_diagnostics, then use search only if those results are " - "insufficient. Returns ranked ids, aliases, URLs, snippets, and match reasons." + 'Search the v8std knowledge base by a natural-language question, topic, or uncertain ' + 'identifier. Returns ranked page IDs, titles, descriptions, URLs, scores and match ' + 'reasons; no full article text. Known diagnostic codes are handled by ' + 'v8std_explain_diagnostics; BSL/SDBL source fragments by v8std_explain_snippet. An exact ' + 'page ID or URL can be read with v8std_get_page. An empty result means no match in this ' + 'corpus, not that the code is correct. Scores rank candidates and are not probabilities.' ), ) def search( - query: str, - limit: int = 10, - types: list[str] | None = None, - mode: str = "hybrid", + query: Annotated[str, Field(description="Question, topic or uncertain identifier; up to 500 Unicode characters. Example: модальные окна. Not a source module.")], + limit: Annotated[int, Field(description="Maximum results, default 10; clamped to 1–50.")] = 10, + types: Annotated[list[str] | None, Field(description="Page types: standard, diagnostic, fix, pattern, service. null or [] means all types.")] = None, + mode: Annotated[str, Field(description="hybrid: combined search (default); exact: includes identifier variants and fuzzy code matches; bm25: text/metadata; semantic: indexed vectors.")] = "hybrid", ) -> dict[str, Any]: result = index.search(query, types=types, mode=mode, limit=limit) tool_usage.record_search(query, result, system=current_client_system()) @@ -562,16 +598,15 @@ def search( @server.tool( name="v8std_get_page", description=( - "Use this when you already have an exact id, alias, source path, HTML URL, " - "or Markdown URL and need the full clean Markdown page text for a standard, " - "diagnostic, pattern, or service page. After v8std_search, " - "v8std_explain_snippet, v8std_explain_diagnostics, or v8std_get_related " - "returns an id, call this to read the authoritative content before " - "explaining or fixing code. Not for discovery; use search, snippet, " - "or diagnostics tools first." + 'Read a known v8std article by ID, alias, source path, HTML URL or Markdown URL, for ' + 'example std437. Returns found, page metadata, Markdown text and body_truncated; an ' + 'unknown page returns found=false and possible candidates. body_limit defaults to 12000 ' + 'characters and is clamped to 1000–30000. A truncated body is incomplete; the returned ' + 'markdown_url identifies the full document. This is document retrieval, not code ' + 'analysis. For an unknown topic, v8std_search finds candidate IDs.' ), ) - def page(id_or_alias_or_url: str, body_limit: int = MAX_BODY_CHARS) -> dict[str, Any]: + def page(id_or_alias_or_url: Annotated[str, Field(description="Known article ID, alias, source path, HTML or Markdown URL; up to 1000 Unicode characters. Example: std437.")], body_limit: Annotated[int, Field(description="Markdown character budget, default 12000; clamped to 1000–30000. Truncation marker may add characters; inspect body_truncated.")] = MAX_BODY_CHARS) -> dict[str, Any]: result = index.page(id_or_alias_or_url, body_limit=body_limit) tool_usage.record_page(id_or_alias_or_url, result, system=current_client_system()) return result @@ -579,26 +614,26 @@ def page(id_or_alias_or_url: str, body_limit: int = MAX_BODY_CHARS) -> dict[str, @server.tool( name="v8std_get_related", description=( - "Use this when you need to move from a known standard or diagnostic to " - "related standards, diagnostics, or EDT/v8-code-style checks. Typical flow: " - "after search, snippet, diagnostics, or page lookup gives an id, call this " - "to collect surrounding rule context for code review, explanation, or " - "remediation planning. Not for arbitrary search; use v8std_search first " - "when no starting id is known." + 'Retrieve explicit corpus links from a known standard or diagnostic article. Returns ' + 'related page IDs, relation types, titles, descriptions and URLs, filtered by relations ' + 'and limited by limit. An unknown starting page returns found=false; an empty related ' + 'list means no matching recorded links. Links provide reading context, not evidence that ' + 'a rule is violated. This tool does not discover arbitrary topics or retrieve full ' + 'article bodies; those operations are v8std_search and v8std_get_page.' ), ) def related( - id_or_alias_or_url: str, - relations: list[str] | None = None, - limit: int = 10, + id_or_alias_or_url: Annotated[str, Field(description="Known article ID, alias, source path, HTML or Markdown URL; up to 1000 Unicode characters. Example: std437.")], + relations: Annotated[list[str] | None, Field(description="Relation kinds: standard, diagnostic, edt_check, related. Legacy related_standard and related_diagnostic accepted. null or [] means all.")] = None, + limit: Annotated[int, Field(description="Maximum results, default 10; clamped to 1–50.")] = 10, ) -> dict[str, Any]: tool_usage.record("v8std_get_related", system=_usage_system(current_client_system())) return index.related(id_or_alias_or_url, relations=relations, limit=limit) def explain_snippet( snippet: str, - language: str = "auto", - limit: int = 10, + language: Annotated[str, Field(description="Source-language hint: auto (default), bsl or sdbl. Currently echoed without selecting a separate analyzer.")] = "auto", + limit: Annotated[int, Field(description="Combined diagnostics and standards budget, default 10; clamped to 1–50.")] = 10, ) -> dict[str, Any]: tool_usage.record("v8std_explain_snippet", system=_usage_system(current_client_system())) return index.explain_snippet(snippet, language=language, limit=limit) @@ -606,82 +641,58 @@ def explain_snippet( # Publish the effective bound without Pydantic echoing source code in a # length ValidationError. The index enforces this same bound on every call. explain_snippet.__annotations__["snippet"] = Annotated[ - str, Field(json_schema_extra={"maxLength": index.max_snippet_chars}) + str, Field(description="One relevant BSL procedure or SDBL fragment, limited by maxLength. Normalized output may contain source text; omit secrets.", json_schema_extra={"maxLength": index.max_snippet_chars}) ] server.tool( name="v8std_explain_snippet", description=( - "Use this when the input is one BSL procedure or SDBL code fragment and the goal " - "is to identify applicable standards, likely diagnostics, and confidence " - "from code tokens or calls, for example ВЫБРАТЬ РАЗРЕШЕННЫЕ, " - "ОткрытьФормуМодально, Предупреждение, or Вопрос. Do not use it for ordinary " - "prose such as 'модальные окна'; use v8std_search for prose. For full rule " - "text, call v8std_get_page on returned ids. " - f"This instance accepts at most {index.max_snippet_chars} Unicode characters. " - "Known signals are checked throughout the accepted fragment; this is not a full analyzer. " - "Send one relevant procedure, not a whole module. On a size error, reduce the fragment; " - "do not retry the same input or automatically fan out a module into parallel calls. " - "An operator may explicitly increase the limit on a local server and restart it. " - "Returns compact previews and at most limit recommendations in total." + 'Match one BSL procedure or SDBL fragment against signals in the v8std knowledge base. ' + 'Returns candidate diagnostics and standards, matched signals and heuristic confidence, ' + 'with at most limit recommendations in total. It does not execute code, inspect a ' + 'repository or run a static analyzer; matches are not confirmed violations and no matches ' + 'do not certify correctness. Full rule text is available through v8std_get_page. ' + 'Diagnostic identifiers from reports or source comments are handled by ' + f'v8std_explain_diagnostics. The fragment limit is {index.max_snippet_chars} Unicode characters; ' + 'a size error requires a smaller relevant fragment, not an unchanged retry. language ' + 'records the supplied hint and currently does not select a separate analyzer.' ), )(explain_snippet) @server.tool( name="v8std_explain_diagnostics", description=( - "Use this when the user has a list of analyzer diagnostic codes from ACC/АПК, " - "BSLLS, EDT, or v8-code-style and needs diagnostic descriptions grouped with " - "linked standard clauses. Accepts values like acc 1245, АПК:361, " - "bslls:AssignAliasFieldsInQuery, or v8cs:*. For the full Markdown text of " - "a diagnostic or standard, call v8std_get_page on returned ids; do not use " - "for raw code snippets." + 'Resolve diagnostic identifiers from analyzer reports or source-code comments and ' + 'suppressions, such as acc 1245, АПК:361, bslls:AssignAliasFieldsInQuery or an exact v8cs ' + 'identifier. Accepts up to 500 strings, each up to 200 characters. Returns known ' + 'diagnostics grouped with linked standards, occurrence frequencies and unknown_codes. ' + 'Empty entries are ignored; repeated codes are counted. Unknown means not resolved in ' + 'this corpus, not a verified invalid diagnostic. Resolving a suppression code does not ' + 'justify the suppression. No wildcard matching or analyzer execution is performed. Full ' + 'descriptions are available through v8std_get_page using returned IDs.' ), ) - def explain_diagnostics(codes: list[str]) -> dict[str, Any]: + def explain_diagnostics(codes: Annotated[list[str], Field(description="Exact diagnostic IDs with analyzer namespace when known; up to 500 entries, each up to 200 characters. Example: [acc 1245, bslls:AssignAliasFieldsInQuery]. Not a raw report.")]) -> dict[str, Any]: result = index.explain_diagnostics(codes) tool_usage.record_diagnostics(codes, result, system=current_client_system()) return result - @server.resource( - "v8std://llms.txt", - name="llms.txt", - description="Compact v8std.ru LLM map.", - mime_type="text/plain", - ) - def llms_txt() -> str: - return index.read_resource_text("llms.txt") - - @server.resource( - "v8std://llms-full.txt", - name="llms-full.txt", - description="Full cleaned Markdown corpus for v8std.ru.", - mime_type="text/plain", - ) - def llms_full_txt() -> str: - return index.read_resource_text("llms-full.txt") - - @server.resource( - "v8std://ai/pages.jsonl", - name="pages.jsonl", - description="Machine-readable v8std.ru pages index.", - mime_type="application/jsonl", - ) - def pages_jsonl() -> str: - return index.read_resource_text("pages.jsonl") - @server.custom_route("/healthz", methods=["GET"], include_in_schema=False) async def healthz(_: Request) -> Response: status = index.status() status_code = 200 if status.get("ok") else 503 return JSONResponse(status, status_code=status_code) + @server.custom_route("/livez", methods=["GET"], include_in_schema=False) + async def livez(_: Request) -> Response: + return JSONResponse({"ok": True}) + @server.custom_route("/version", methods=["GET"], include_in_schema=False) async def version(_: Request) -> Response: return JSONResponse( {"service": "v8std-mcp", "api": "v2", "api_profiles": MCP_API_PROFILES, **index.status()} ) - install_self_documenting_mcp_app(server, mcp_path=mcp_path) + install_self_documenting_mcp_app(server, mcp_path=mcp_path, index=index) return server @@ -690,9 +701,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run the v8std.ru read-only MCP server.") parser.add_argument("--pages", type=Path, help="Read pages JSONL from a local file.") parser.add_argument("--vectors", type=Path, help="Read search vectors JSONL from a local file.") - parser.add_argument("--index-url", default=DEFAULT_INDEX_URL, help="Remote pages JSONL URL.") - parser.add_argument("--vectors-url", default=DEFAULT_VECTORS_URL, help="Remote vectors JSONL URL.") - parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR) + parser.add_argument("--index-url", default=None, help="Explicit legacy remote pages JSONL URL.") + parser.add_argument("--vectors-url", default=None, help="Explicit legacy remote vectors JSONL URL.") + parser.add_argument("--site-url", default=None, help="Snapshot source and presentation site URL.") + parser.add_argument("--transport", choices=["streamable-http"], default="streamable-http") + parser.add_argument("--cache-dir", default=None, help="Overrides V8STD_MCP_CACHE_DIR.") parser.add_argument("--refresh-seconds", type=int, default=DEFAULT_REFRESH_SECONDS) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8765) @@ -732,6 +745,25 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="Allowed Origin header for MCP transport security. Can be repeated.", ) args = parser.parse_args(argv) + cache = (args.cache_dir if args.cache_dir is not None + else os.environ.get("V8STD_MCP_CACHE_DIR", str(DEFAULT_CACHE_DIR))) + if not cache.strip(): + parser.error("--cache-dir / V8STD_MCP_CACHE_DIR must not be empty") + args.cache_dir = Path(cache) + if not 0 <= args.port <= 65535: + parser.error("port must be from 0 to 65535") + site = args.site_url if args.site_url is not None else os.environ.get("V8STD_MCP_SITE_URL") + legacy = any(value is not None for value in (args.pages, args.vectors, args.index_url, args.vectors_url)) + if legacy and site is not None: + parser.error("explicit legacy sources cannot be combined with SITE_URL") + if args.refresh_seconds < 0: + parser.error("refresh-seconds must be nonnegative") + try: + args.site_url = None if legacy else normalize_site_url(site if site is not None else DEFAULT_SITE_URL) + except SnapshotError: + parser.error("invalid SITE_URL") + args.index_url = args.index_url if args.index_url is not None else DEFAULT_INDEX_URL + args.vectors_url = args.vectors_url if args.vectors_url is not None else DEFAULT_VECTORS_URL raw_limit = args.max_snippet_chars if raw_limit is None: raw_limit = os.environ.get("V8STD_MCP_MAX_SNIPPET_CHARS", str(MAX_SNIPPET_CHARS)) @@ -751,16 +783,21 @@ def parse_args(argv: list[str]) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: args = parse_args(argv if argv is not None else sys.argv[1:]) configure_runtime_logging(args.log_level) - index = V8StdIndex( - pages_path=args.pages, - vectors_path=args.vectors, - index_url=args.index_url, - vectors_url=args.vectors_url, - cache_dir=args.cache_dir, - refresh_seconds=args.refresh_seconds, - max_snippet_chars=args.max_snippet_chars, - ) - index.load() + if args.site_url is not None: + index = SnapshotIndex(site_url=args.site_url, cache_dir=args.cache_dir, + refresh_seconds=args.refresh_seconds, max_snippet_chars=args.max_snippet_chars, + runtime_sha=os.environ.get("V8STD_MCP_RUNTIME_SHA")) + else: + index = V8StdIndex( + pages_path=args.pages, + vectors_path=args.vectors, + index_url=args.index_url, + vectors_url=args.vectors_url, + cache_dir=args.cache_dir, + refresh_seconds=args.refresh_seconds, + max_snippet_chars=args.max_snippet_chars, + ) + index.load() server = build_server( index, @@ -772,7 +809,7 @@ def main(argv: list[str] | None = None) -> int: log_level=args.log_level, usage_logger=McpToolUsageLogger(args.usage_log), ) - server.run(transport="streamable-http") + server.run(transport=args.transport) return 0 diff --git a/runtime/v8std_mcp_snapshot_format.py b/runtime/v8std_mcp_snapshot_format.py new file mode 100644 index 0000000..13a5d38 --- /dev/null +++ b/runtime/v8std_mcp_snapshot_format.py @@ -0,0 +1,459 @@ +"""Bounded, stdlib-only validation for MCP_CORPUS_SNAPSHOT@1.0. + +No filesystem extraction or network access occurs here. The cache loader owns +private staging and the trust decision for the selected site's archive URL. +""" + +from __future__ import annotations + +import base64 +import binascii +from dataclasses import dataclass +import hashlib +import io +import ipaddress +import json +import math +import re +import struct +import tarfile +from urllib.parse import quote, unquote, urlsplit +import zlib + +from scripts.v8std_mcp_chunks import page_chunks + + +DEFAULT_SITE_URL = "https://v8std.ru/" +PUBLIC_DELIVERY_URL = "https://ai.v8std.ru/indexes/v1/" +VECTOR_MODEL = "v8std-hash-embeddings-v1" +VECTOR_DIM = 256 +MAX_MANIFEST_BYTES = 64 * 1024 +MAX_ARCHIVE_BYTES = 16 * 1024 * 1024 +MAX_UNPACKED_BYTES = 64 * 1024 * 1024 +MAX_JSONL_LINE_BYTES = 1024 * 1024 +MAX_JSONL_ROWS = 100_000 +MAX_JSON_DEPTH = 32 +MEMBER_LIMITS = { + "metadata.json": 64 * 1024, + "pages.jsonl": 16 * 1024 * 1024, + "search-vectors.jsonl": 32 * 1024 * 1024, + "llms.txt": 4 * 1024 * 1024, + "llms-full.txt": 16 * 1024 * 1024, +} +MEMBERS = tuple(MEMBER_LIMITS) +JSONL_MEMBERS = ("pages.jsonl", "search-vectors.jsonl") +_READ_BYTES = 64 * 1024 +_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_SOURCE_SHA = re.compile(r"[0-9a-f]{40}\Z") +_ERROR_CODES = frozenset({ + "snapshot_invalid", "site_url", "manifest_schema", "manifest_size", "archive_path", + "archive_size", "archive_hash", "archive_unpacked_size", "archive_gzip", + "archive_member", "archive_header", "archive_padding", "member_size", "member_hash", + "metadata_schema", "metadata_hash", "metadata_mismatch", "json_encoding", "json_syntax", + "json_duplicate_key", "json_depth", "json_type", "json_number", "jsonl_line_size", + "jsonl_rows", "page_schema", "page_id", "page_path", "vector_schema", "vector_identity", + "vector_data", "vector_text_hash", "corpus_empty", "source_io", "publish_io", + "immutable_conflict", +}) + + +class SnapshotError(ValueError): + """An error safe to put in status/logs; never embeds input data or paths.""" + + def __init__(self, code: str): + self.code = code if code in _ERROR_CODES else "snapshot_invalid" + super().__init__(self.code) + + +@dataclass(frozen=True) +class VerifiedSnapshot: + metadata: dict + files: dict[str, bytes] + archive_sha256: str + + +def sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _require(condition: bool, code: str) -> None: + if not condition: + raise SnapshotError(code) + + +def _path(value: str, *, absolute: bool, code: str) -> str: + _require(isinstance(value, str), code) + _require(not re.search(r"[\x00-\x20\x7f\\?#:]", value), code) + _require(not re.search(r"%(?![0-9a-fA-F]{2})|%(?:2f|5c|25)", value, re.I), code) + try: + decoded = unquote(value, encoding="utf-8", errors="strict") + decoded.encode("utf-8") + except UnicodeError: + raise SnapshotError(code) from None + _require(not re.search(r"[\x00-\x20\x7f\\?#:]", decoded), code) + _require("//" not in decoded and not any(p in {".", ".."} for p in decoded.split("/")), code) + _require(not value or value.startswith("/") == absolute, code) + # Decode/encode once to give percent-encoded Unicode and unreserved bytes a + # single spelling, without allowing encoded separators or double decoding. + return quote(decoded, safe="/!$&'()*+,;=@-._~") + + +def _url(value: str, code: str) -> tuple[str, str, str]: + _require(isinstance(value, str) and bool(value), code) + _require(not re.search(r"[\x00-\x20\x7f\\?#]", value), code) + try: + parts = urlsplit(value) + _require(parts.scheme.lower() in {"https", "http"}, code) + _require(bool(parts.netloc) and "@" not in parts.netloc, code) + host = parts.hostname + _require(bool(host) and "%" not in host, code) + if ":" in host: + host = "[" + ipaddress.IPv6Address(host).compressed + "]" + else: + host = host.encode("idna").decode("ascii").lower() + _require(bool(re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", host)), code) + _require(all(label and len(label) <= 63 and not label.startswith("-") + and not label.endswith("-") for label in host.split(".")), code) + port = parts.port + _require(port is None or 0 < port <= 65535, code) + _require(not parts.netloc.endswith(":"), code) + scheme = parts.scheme.lower() + authority = host + if port is not None and port != {"http": 80, "https": 443}[scheme]: + authority += f":{port}" + return scheme, authority, _path(parts.path or "/", absolute=True, code=code) + except (ValueError, UnicodeError): + raise SnapshotError(code) from None + + +def normalize_site_url(value: str) -> str: + _require(isinstance(value, str), "site_url") + scheme, host, path = _url(value.strip(), "site_url") + return f"{scheme}://{host}{path.rstrip('/')}/" + + +def canonical_page_path(value: str, canonical_site_url: str) -> str: + """Return a validated relative path, retaining the canonical site's prefix.""" + scheme, host, path = _url(value, "page_path") + base_scheme, base_host, base_path = _url(canonical_site_url, "page_path") + _require((scheme, host) == (base_scheme, base_host) and path.startswith(base_path), "page_path") + return _path(path[len(base_path):], absolute=False, code="page_path") + + +def _check_tree(value, *, no_floats: bool = False, level: int = 0) -> None: + if isinstance(value, (dict, list)): + _require(level < MAX_JSON_DEPTH, "json_depth") + if isinstance(value, list): + _require(len(value) <= MAX_JSONL_ROWS, "json_type") + children = value + else: + _require(all(isinstance(key, str) for key in value), "json_type") + children = [*value.keys(), *value.values()] + for child in children: + _check_tree(child, no_floats=no_floats, level=level + 1) + elif isinstance(value, str): + try: + value.encode("utf-8") + except UnicodeError: + raise SnapshotError("json_encoding") from None + elif type(value) is float: + _require(not no_floats and math.isfinite(value), "json_number") + else: + _require(value is None or type(value) in {int, bool}, "json_type") + + +def canonical_json(value: dict) -> bytes: + """Descriptor encoding; floats, including floats in unknown fields, fail.""" + _check_tree(value, no_floats=True) + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + except (ValueError, TypeError, RecursionError): + raise SnapshotError("json_type") from None + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + _require(key not in result, "json_duplicate_key") + result[key] = value + return result + + +def _invalid_constant(value): + raise SnapshotError("json_number") + + +def strict_json(payload: bytes) -> dict: + """Check nesting before json.loads can construct a deeply nested tree.""" + _require(isinstance(payload, bytes), "json_type") + try: + text = payload.decode("utf-8") + except UnicodeError: + raise SnapshotError("json_encoding") from None + depth, quoted, escaped = 0, False, False + for char in text: + if quoted: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + quoted = False + elif char == '"': + quoted = True + elif char in "[{": + depth += 1 + _require(depth <= MAX_JSON_DEPTH, "json_depth") + elif char in "]}": + depth -= 1 + try: + value = json.loads(text, object_pairs_hook=_unique_object, parse_constant=_invalid_constant) + except SnapshotError: + raise + except (ValueError, RecursionError): + raise SnapshotError("json_syntax") from None + _require(isinstance(value, dict), "json_type") + _check_tree(value) + return value + + +def _integer(value, minimum: int, maximum: int, code: str) -> None: + _require(type(value) is int and minimum <= value <= maximum, code) + + +def _hash(value, code: str) -> None: + _require(isinstance(value, str) and bool(_SHA256.fullmatch(value)), code) + + +def _schema(value: dict, code: str) -> None: + _integer(value.get("schema_version"), 1, 1, code) + _integer(value.get("vector_dim"), VECTOR_DIM, VECTOR_DIM, code) + _require(value.get("vector_model") == VECTOR_MODEL, code) + source = value.get("source_sha") + _require(isinstance(source, str) and bool(_SOURCE_SHA.fullmatch(source)), code) + _hash(value.get("corpus_id"), code) + + +def _manifest(manifest: dict) -> dict: + _require(isinstance(manifest, dict), "manifest_schema") + _check_tree(manifest) + # verify_archive is also a public entry point: a dict passed directly must + # not bypass the byte limit enforced when reading manifest JSON from bytes. + try: + encoded = json.dumps(manifest, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + except (ValueError, TypeError): + raise SnapshotError("json_number") from None + _require(len(encoded) <= MAX_MANIFEST_BYTES, "manifest_size") + _schema(manifest, "manifest_schema") + archive = manifest.get("archive") + _require(isinstance(archive, dict), "manifest_schema") + _hash(archive.get("sha256"), "manifest_schema") + _integer(archive.get("bytes"), 1, MAX_ARCHIVE_BYTES, "archive_size") + _integer(archive.get("unpacked_bytes"), 1, MAX_UNPACKED_BYTES, "archive_unpacked_size") + relative = archive["sha256"] + "/snapshot.tar.gz" + _require(archive.get("path") in (relative, PUBLIC_DELIVERY_URL + relative), "archive_path") + return manifest + + +def validate_manifest(payload: bytes) -> dict: + _require(isinstance(payload, bytes), "manifest_schema") + _require(len(payload) <= MAX_MANIFEST_BYTES, "manifest_size") + return _manifest(strict_json(payload)) + + +def jsonl_rows(payload: bytes): + """Yield validated objects, checking every line (including whitespace).""" + count = 0 + # BytesIO.readline avoids allocating a list containing every raw line. + stream = io.BytesIO(payload) + while line := stream.readline(MAX_JSONL_LINE_BYTES + 2): + content = line.removesuffix(b"\n") + _require(len(content) <= MAX_JSONL_LINE_BYTES, "jsonl_line_size") + if not content.strip(b" \t\r"): + continue + count += 1 + _require(count <= MAX_JSONL_ROWS, "jsonl_rows") + yield strict_json(content) + + +def _gunzip(payload: bytes) -> bytearray: + _require(payload[:8] == b"\x1f\x8b\x08\x00\x00\x00\x00\x00", "archive_gzip") + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + unpacked = bytearray() + try: + for offset in range(0, len(payload), _READ_BYTES): + chunk = payload[offset:offset + _READ_BYTES] + while chunk: + block = decoder.decompress(chunk, min(_READ_BYTES, MAX_UNPACKED_BYTES - len(unpacked) + 1)) + unpacked.extend(block) + _require(len(unpacked) <= MAX_UNPACKED_BYTES, "archive_unpacked_size") + chunk = decoder.unconsumed_tail + if decoder.eof: + _require(not decoder.unused_data and offset + _READ_BYTES >= len(payload), "archive_gzip") + break + _require(decoder.eof, "archive_gzip") + except zlib.error: + raise SnapshotError("archive_gzip") from None + return unpacked + + +def tar_header(name: str, size: int) -> bytes: + """The producer and verifier share one exact USTAR header definition.""" + info = tarfile.TarInfo(name) + info.size = size + info.mode = 0o644 + info.uid = info.gid = info.mtime = 0 + info.uname = info.gname = "" + return info.tobuf(format=tarfile.USTAR_FORMAT, encoding="ascii", errors="strict") + + +def _tar_files(raw: bytearray) -> dict[str, bytes]: + files = {} + offset = 0 + for name in MEMBERS: + header = bytes(raw[offset:offset + 512]) + _require(len(header) == 512 and any(header), "archive_member") + # Check raw fields before tarfile can hide extensions or combine prefix/name. + _require(header[:100].split(b"\0", 1)[0] == name.encode("ascii") + and header[156:157] == tarfile.REGTYPE, "archive_member") + try: + info = tarfile.TarInfo.frombuf(header, encoding="ascii", errors="strict") + except (tarfile.HeaderError, ValueError, UnicodeError): + raise SnapshotError("archive_header") from None + _integer(info.size, 0, MEMBER_LIMITS[name], "member_size") + _require(header == tar_header(name, info.size), "archive_header") + start = offset + 512 + end = start + info.size + offset = end + (-info.size % 512) + _require(offset <= len(raw), "archive_member") + _require(not any(raw[end:offset]), "archive_padding") + files[name] = bytes(raw[start:end]) + _require(len(raw) - offset >= 1024 and len(raw) % 512 == 0, "archive_member") + _require(not any(raw[offset:]), "archive_member") + return files + + +def _string(value, code: str, *, nonempty: bool = False) -> None: + _require(isinstance(value, str) and (not nonempty or bool(value.strip())), code) + + +def _page_schema(page: dict) -> None: + _string(page.get("id"), "page_id", nonempty=True) + for key in ("title", "description", "body_markdown", "type", "source_path"): + if key in page: + _string(page[key], "page_schema") + for key in ("aliases", "source_urls"): + values = page.get(key, []) + _require(isinstance(values, list), "page_schema") + for value in values: + _string(value, "page_schema") + related = page.get("related", []) + _require(isinstance(related, list), "page_schema") + for entry in related: + _require(isinstance(entry, dict), "page_schema") + _string(entry.get("id"), "page_schema", nonempty=True) + for key in ("title", "type", "relation", "url", "markdown_url", "source_path"): + if key in entry: + _string(entry[key], "page_schema") + if "source_path" in page: + _path(page["source_path"], absolute=False, code="page_path") + + +def portable_page(page: dict, canonical_site_url: str) -> dict: + """Add only presentation paths; retain the exact canonical retrieval fields.""" + _page_schema(page) + paths = { + "site_path": canonical_page_path(page.get("url"), canonical_site_url), + "markdown_path": canonical_page_path(page.get("markdown_url"), canonical_site_url), + } + for key, expected in paths.items(): + if key in page: + _require(page[key] == expected, "page_path") + return {**page, **paths} + + +def _semantics(files: dict[str, bytes], site_url: str) -> dict[str, int]: + ids = set() + expected = {} + for page in jsonl_rows(files["pages.jsonl"]): + portable = portable_page(page, site_url) + _require(all(key in page and page[key] == portable[key] + for key in ("site_path", "markdown_path")), "page_path") + _require(page["id"] not in ids, "page_id") + ids.add(page["id"]) + for field, index, text in page_chunks(page): + expected[page["id"], field, index] = sha256(text.encode("utf-8")) + _require(len(expected) <= MAX_JSONL_ROWS, "jsonl_rows") + _require(bool(ids), "corpus_empty") + count = 0 + for row in jsonl_rows(files["search-vectors.jsonl"]): + _string(row.get("id"), "vector_schema", nonempty=True) + _string(row.get("field"), "vector_schema") + _integer(row.get("chunk_index"), 0, MAX_JSONL_ROWS - 1, "vector_schema") + _integer(row.get("dim"), VECTOR_DIM, VECTOR_DIM, "vector_schema") + _require(row.get("model") == VECTOR_MODEL, "vector_schema") + identity = row["id"], row["field"], row["chunk_index"] + # Removing matched identities detects duplicates, missing chunks, and + # dangling rows with the same rule; no partially valid vectors survive. + _require(identity in expected, "vector_identity") + _hash(row.get("text_sha256"), "vector_text_hash") + _require(row["text_sha256"] == expected.pop(identity), "vector_text_hash") + encoded = row.get("vector_base64") + _require(isinstance(encoded, str) and len(encoded) == 1368, "vector_data") + try: + decoded = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError): + raise SnapshotError("vector_data") from None + _require(len(decoded) == 4 * VECTOR_DIM + and base64.b64encode(decoded).decode("ascii") == encoded, "vector_data") + _require(all(math.isfinite(value) for value in struct.unpack("<256f", decoded)), "vector_data") + count += 1 + _require(count > 0, "corpus_empty") + _require(not expected, "vector_identity") + return {"pages.jsonl": len(ids), "search-vectors.jsonl": count} + + +def _metadata(files: dict[str, bytes], manifest: dict) -> dict: + metadata = strict_json(files["metadata.json"]) + _schema(metadata, "metadata_schema") + descriptor = {key: value for key, value in metadata.items() if key != "corpus_id"} + _require(sha256(canonical_json(descriptor)) == metadata["corpus_id"], "metadata_hash") + for key in ("schema_version", "source_sha", "corpus_id", "vector_model", "vector_dim"): + _require(metadata[key] == manifest[key], "metadata_mismatch") + site_url = metadata.get("canonical_site_url") + _require(normalize_site_url(site_url) == site_url, "metadata_schema") + entries = metadata.get("files") + _require(isinstance(entries, dict) and set(entries) == set(MEMBERS[1:]), "metadata_schema") + for name, entry in entries.items(): + _require(isinstance(entry, dict), "metadata_schema") + _integer(entry.get("bytes"), 0, MEMBER_LIMITS[name], "member_size") + _hash(entry.get("sha256"), "metadata_schema") + _require(len(files[name]) == entry["bytes"], "member_size") + _require(sha256(files[name]) == entry["sha256"], "member_hash") + if name in JSONL_MEMBERS: + _integer(entry.get("rows"), 1, MAX_JSONL_ROWS, "jsonl_rows") + else: + try: + files[name].decode("utf-8") + except UnicodeError: + raise SnapshotError("json_encoding") from None + counts = _semantics(files, site_url) + for name, count in counts.items(): + _require(count == entries[name]["rows"], "jsonl_rows") + return metadata + + +def verify_archive(payload: bytes, manifest: dict) -> VerifiedSnapshot: + """Verify exact compressed bytes, framing, descriptors and complete semantics.""" + _manifest(manifest) + _require(isinstance(payload, bytes), "archive_size") + _require(len(payload) <= MAX_ARCHIVE_BYTES + and len(payload) == manifest["archive"]["bytes"], "archive_size") + digest = sha256(payload) + _require(digest == manifest["archive"]["sha256"], "archive_hash") + files = _tar_files(_gunzip(payload)) + _require(sum(map(len, files.values())) == manifest["archive"]["unpacked_bytes"], "archive_unpacked_size") + metadata = _metadata(files, manifest) + return VerifiedSnapshot(metadata=metadata, files=files, archive_sha256=digest) diff --git a/runtime/v8std_mcp_snapshots.py b/runtime/v8std_mcp_snapshots.py new file mode 100644 index 0000000..55f8105 --- /dev/null +++ b/runtime/v8std_mcp_snapshots.py @@ -0,0 +1,766 @@ +"""Bounded snapshot loading and immutable-generation coordination. + +Only the application's own spawned worker uses pickle, over a private socketpair. +Disk and HTTP inputs are always bytes/strict JSON verified by the format module. +Builders and their results must be spawn-serializable (including bounded local +result reconstruction). Network, verification, build and commit run in the child; +the supervisor can terminate and reap it even inside DNS or a blocking builder. + +Cache layout: /v1-/{state.json,rollback.json, +pins.json,generations//...}. Host-owned pins.json is an atomic +JSON object {"archives": [, ...]}. Invalid pins fail GC closed. +Python references returned by current() retain old in-memory generations for +in-flight requests; their lifetime is independent of disk-generation retention. +""" + +from __future__ import annotations + +from contextlib import contextmanager +import fcntl +import http.client +import io +import multiprocessing +import os +from pathlib import Path +import pickle +import random +import re +import select +import shutil +import socket +import ssl +import stat +import struct +import tarfile +import tempfile +import threading +import time +from urllib.parse import urlsplit + +from runtime.v8std_mcp_snapshot_format import ( + DEFAULT_SITE_URL, PUBLIC_DELIVERY_URL, MAX_ARCHIVE_BYTES, MAX_MANIFEST_BYTES, + MEMBER_LIMITS, SnapshotError, VerifiedSnapshot, canonical_json, + canonical_page_path, normalize_site_url, sha256, strict_json, + validate_manifest, verify_archive, +) + +ATTEMPT_SECONDS = 360 +READ_SECONDS = 20 +CACHE_BYTES = 256 * 1024 * 1024 +_CHUNK = 64 * 1024 +_DIGEST = re.compile(r"[0-9a-f]{64}\Z") +_TEMP = re.compile(r"\.(?:stage|pointer)-[a-z0-9_]+\Z") +_CODES = frozenset({ + "loader_failed", "INDEX_NOT_READY", "url_policy", "redirect_limit", + "http_status", "http_headers", "http_encoding", "http_size", "network", + "deadline", "closed", "cache_io", "cache_budget", "lock_timeout", + "prepare_failed", "worker_failed", "configuration", +}) + + +class LoaderError(ValueError): + """Bounded loader diagnostics; never include URLs, input data or raw errors.""" + + def __init__(self, code: str): + self.code = code if code in _CODES else "loader_failed" + super().__init__(self.code) + + +def _remaining(deadline): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise LoaderError("deadline") + return remaining + + +def _allowed_url(reference: str, current: str, boundary: str) -> str: + # Do not urljoin first: it removes dot segments before they can be rejected. + try: + if not isinstance(reference, str) or not reference or reference.startswith("//"): + raise LoaderError("url_policy") + if not urlsplit(reference).scheme: + if reference.startswith("/"): + parts = urlsplit(current) + reference = f"{parts.scheme}://{parts.netloc}" + reference + else: + reference = current.rsplit("/", 1)[0] + "/" + reference + return boundary + canonical_page_path(reference, boundary) + except (SnapshotError, ValueError): + raise LoaderError("url_policy") from None + + +def _archive_url(manifest, manifest_url, site_url): + path = manifest["archive"]["path"] + boundary = site_url + if site_url == DEFAULT_SITE_URL and path.startswith(PUBLIC_DELIVERY_URL): + boundary = PUBLIC_DELIVERY_URL + return _allowed_url(path, manifest_url, boundary), boundary + + +def _download(url, boundary, headers, limit, deadline, read_seconds, *, archive=False): + """One bounded HTTP exchange, validating each redirect before connecting.""" + for redirects in range(4): + url = _allowed_url(url, url, boundary) + parts = urlsplit(url) + timeout = min(read_seconds, _remaining(deadline)) + if parts.scheme == "https": + connection = http.client.HTTPSConnection( + parts.hostname, parts.port, timeout=timeout, context=ssl.create_default_context()) + else: + connection = http.client.HTTPConnection(parts.hostname, parts.port, timeout=timeout) + try: + # Explicit connect retains the socket even when getresponse detaches + # a Connection: close response. DNS is bounded by the parent process. + connection.connect() + stream_socket = connection.sock + stream_socket.settimeout(min(read_seconds, _remaining(deadline))) + connection.request("GET", parts.path, headers={ + "Accept-Encoding": "identity", "User-Agent": "v8std-snapshot/1", **headers}) + stream_socket.settimeout(min(read_seconds, _remaining(deadline))) + response = connection.getresponse() + with response: + if response.status in {301, 302, 303, 307, 308}: + if redirects == 3: + raise LoaderError("redirect_limit") + locations = response.headers.get_all("Location", []) + if len(locations) != 1: + raise LoaderError("http_headers") + url = _allowed_url(locations[0], url, boundary) + continue + if response.status == 304: + return 304, b"", {}, url + if response.status != 200: + raise LoaderError("http_status") + encodings = response.headers.get_all("Content-Encoding", []) + if encodings and encodings != ["identity"]: + raise LoaderError("http_encoding") + if archive and response.headers.get_content_type() != "application/gzip": + raise LoaderError("http_headers") + lengths = response.headers.get_all("Content-Length", []) + length = None + if lengths: + if len(lengths) != 1 or not re.fullmatch(r"[0-9]{1,10}", lengths[0]): + raise LoaderError("http_headers") + length = int(lengths[0]) + if length > limit: + raise LoaderError("http_size") + transfers = response.headers.get_all("Transfer-Encoding", []) + if transfers and (transfers != ["chunked"] or lengths): + raise LoaderError("http_headers") + payload = bytearray() + while not response.isclosed(): + stream_socket.settimeout(min(read_seconds, _remaining(deadline))) + chunk = response.read1(min(_CHUNK, limit - len(payload) + 1)) + if not chunk: + break + payload.extend(chunk) + if len(payload) > limit: + raise LoaderError("http_size") + if length is not None and length != len(payload): + raise LoaderError("http_size") + _remaining(deadline) + validators = {} + for name in ("ETag", "Last-Modified"): + values = response.headers.get_all(name, []) + if len(values) == 1 and _safe_header(values[0]): + validators[name] = values[0] + return 200, bytes(payload), validators, url + except (TimeoutError, OSError, http.client.HTTPException): + _remaining(deadline) + raise LoaderError("network") from None + finally: + connection.close() + raise LoaderError("redirect_limit") + + +def _safe_header(value): + return (isinstance(value, str) and 0 < len(value) <= 1024 + and all(32 <= ord(char) < 127 for char in value)) + + +def _directory(path, *, create=False): + if create and not path.exists(): + _directory(path.parent, create=True) + path.mkdir(mode=0o700, exist_ok=True) + # Persist each newly created directory entry, including intermediate + # parents. An existing mounted cache needs no writes to the container root. + _fsync_directory(path.parent) + if not stat.S_ISDIR(path.lstat().st_mode): + raise LoaderError("cache_io") + + +def _read_file(path, limit): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > limit: + raise LoaderError("cache_io") + payload = stream.read(limit + 1) + if len(payload) > limit: + raise LoaderError("cache_io") + return payload + + +def _fsync_directory(path): + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(fd) + finally: + os.close(fd) + + +@contextmanager +def _file_lock(path, deadline): + fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600) + waited = False + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise LoaderError("cache_io") + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + waited = True + if deadline - time.monotonic() <= 0: + raise LoaderError("lock_timeout") + time.sleep(max(0, min(.025, deadline - time.monotonic()))) + yield waited + finally: + os.close(fd) # OS releases flock even on process termination/crash. + + +def _prepare(snapshot, prepare, *, current_archive=None): + try: + metadata = {"corpus_id": snapshot.metadata["corpus_id"], + "source_sha": snapshot.metadata["source_sha"], + "archive_sha256": snapshot.archive_sha256} + if current_archive == snapshot.archive_sha256: + # Only a verified reusable entry may take this private path. The + # caller's identity belongs to its already accepted ready generation. + result = None + metadata["unchanged"] = True + else: + result = snapshot if prepare is None else prepare(snapshot) + # Serialization is also preparation: an unpickleable lock must not + # advance the disk pointer. These bytes go ONLY to our private IPC socket. + return pickle.dumps(("ok", result, metadata), protocol=pickle.HIGHEST_PROTOCOL) + except Exception: + raise LoaderError("prepare_failed") from None + + +class SnapshotStore: + def __init__(self, site_url: str, cache_dir: Path): + self.site_url = normalize_site_url(site_url) + self.cache_dir = Path(cache_dir).absolute() + self.namespace = self.cache_dir / ("v1-" + sha256(self.site_url.encode("utf-8"))) + self._attempt_seconds = ATTEMPT_SECONDS + self._read_seconds = READ_SECONDS + self._transport = _download + + def _states(self): + for name in ("state.json", "rollback.json"): + try: + state = strict_json(_read_file(self.namespace / name, MAX_MANIFEST_BYTES)) + if (type(state.get("schema_version")) is not int or state["schema_version"] != 1 + or state.get("site_url") != self.site_url + or not self._digest(state.get("active")) + or (state.get("previous") is not None + and not self._digest(state["previous"]))): + continue + # Recovery retains this record for the next rollback commit, + # including unknown fields. Reject it before selecting a corpus + # unless that same commit serializer can represent every field. + canonical_json(state) + yield state + except (OSError, SnapshotError, LoaderError): + continue + + @staticmethod + def _digest(value): + return isinstance(value, str) and bool(_DIGEST.fullmatch(value)) + + def _generation(self, digest, manifest=None): + directory = self.namespace / "generations" / digest + _directory(directory) + stored_manifest = validate_manifest(_read_file(directory / "manifest.json", MAX_MANIFEST_BYTES)) + if stored_manifest["archive"]["sha256"] != digest: + raise LoaderError("cache_io") + archive = _read_file(directory / "snapshot.tar.gz", MAX_ARCHIVE_BYTES) + snapshot = verify_archive(archive, stored_manifest) + if manifest is not None and manifest != stored_manifest: + snapshot = verify_archive(archive, manifest) + for name, payload in snapshot.files.items(): + if _read_file(directory / name, MEMBER_LIMITS[name]) != payload: + raise LoaderError("cache_io") + return snapshot, stored_manifest + + def _cached_entry(self): + try: + _directory(self.cache_dir) + _directory(self.namespace) + _directory(self.namespace / "generations") + seen = set() + for state in self._states(): + for digest in (state["active"], state.get("previous")): + if digest is None or digest in seen: + continue + seen.add(digest) + try: + snapshot, manifest = self._generation(digest) + validators = state.get("validators", {}) if digest == state["active"] else {} + if not isinstance(validators, dict): + validators = {} + recovered = {**state, "active": digest, "validators": { + k: v for k, v in validators.items() + if k in {"ETag", "Last-Modified"} and _safe_header(v)}} + return snapshot, manifest, recovered + except (OSError, SnapshotError, LoaderError): + continue + except (OSError, LoaderError): + pass + return None + + def cached(self) -> VerifiedSnapshot | None: + """Verify local bytes, without network; coordinator calls this in a child.""" + entry = self._cached_entry() + return entry[0] if entry else None + + def refresh(self, *, prepare=None): + """Supervise a complete attempt; return preparation only after commit.""" + return self._run("refresh", prepare)[0] + + def _usage(self): + total = 0 + # Every namespace, pin and staging file in the shared cache volume + # counts. Never follow a symlink into another tree. + for directory, dirs, files in os.walk(self.cache_dir, followlinks=False, + onerror=lambda error: _cache_walk_error()): + for name in [*dirs, *files]: + info = (Path(directory) / name).lstat() + if not stat.S_ISDIR(info.st_mode): + total += info.st_size + return total + + def _space(self, needed): + if self._usage() + needed > CACHE_BYTES: + raise LoaderError("cache_budget") + + def _write(self, path, payload, deadline): + self._write_stream(path, io.BytesIO(payload), len(payload), deadline) + + def _write_stream(self, path, source, size, deadline): + _remaining(deadline) + self._space(size) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as stream: + remaining = size + while remaining: + _remaining(deadline) + chunk = source.read(min(remaining, _CHUNK)) + if not chunk or len(chunk) > remaining: + raise LoaderError("cache_io") + stream.write(chunk) + remaining -= len(chunk) + stream.flush() + os.fsync(stream.fileno()) + + def _extract_verified(self, source, snapshot, stage, deadline): + """Stream the already-verified immutable archive into private staging. + + Task1 alone owns format/semantic rules. It verifies bytes before this + filesystem step; its buffers are not a substitute for streaming disk + extraction. Destinations and sizes come only from that verified result, + never from unverified tar paths. No extract/extractall filesystem API. + """ + with tarfile.open(fileobj=source, mode="r|gz") as reader: + for name, payload in snapshot.files.items(): + with reader.extractfile(reader.next()) as member: + self._write_stream(stage / name, member, len(payload), deadline) + + def _atomic_file(self, path, payload, deadline): + temporary = self.namespace / (".pointer-" + os.urandom(12).hex()) + try: + self._write(temporary, payload, deadline) + os.replace(temporary, path) + _fsync_directory(self.namespace) + finally: + temporary.unlink(missing_ok=True) + + def _commit_state(self, state, old, deadline): + old_bytes = canonical_json(old) if old else None + if old_bytes is not None: + self._atomic_file(self.namespace / "rollback.json", old_bytes, deadline) + pointer = self.namespace / "state.json" + temporary = self.namespace / (".pointer-" + os.urandom(12).hex()) + replaced = False + try: + self._write(temporary, canonical_json(state), deadline) + _remaining(deadline) + os.replace(temporary, pointer) + replaced = True + _fsync_directory(self.namespace) + except OSError: + if replaced: + if old_bytes is not None: + # rollback.json was fsynced before activation. Its atomic + # rename restores the old pointer even if fsync keeps failing. + os.replace(self.namespace / "rollback.json", pointer) + else: + pointer.unlink(missing_ok=True) + try: + _fsync_directory(self.namespace) + except OSError: + pass + raise + finally: + temporary.unlink(missing_ok=True) + + def _gc(self, retained=()): + states = list(self._states()) + protected = set(retained) + if states: + protected.update(d for d in (states[0]["active"], states[0].get("previous")) if d) + try: + pins = strict_json(_read_file(self.namespace / "pins.json", MAX_MANIFEST_BYTES)) + except FileNotFoundError: + pins = {"archives": []} + archives = pins.get("archives") + if not isinstance(archives, list) or not all(self._digest(d) for d in archives): + raise LoaderError("cache_io") + protected.update(archives) + # A release-managed process may lag the durable pointer after worker IPC + # failure. Its last accepted bytes must survive subsequent refresh GC. + try: + runtime = strict_json(_read_file(self.namespace / "runtime-pin.json", MAX_MANIFEST_BYTES)) + if not self._digest(runtime.get("archive")): + raise LoaderError("cache_io") + protected.add(runtime["archive"]) + except FileNotFoundError: + pass + for entry in self.namespace.iterdir(): + if _TEMP.fullmatch(entry.name): + self._remove_owned(entry) + generations = self.namespace / "generations" + for entry in generations.iterdir(): + if self._digest(entry.name) and entry.name not in protected: + self._remove_owned(entry) + + @staticmethod + def _remove_owned(path): + mode = path.lstat().st_mode + if stat.S_ISDIR(mode): + shutil.rmtree(path) # fd-based, symlink-attack-resistant on supported POSIX. + elif stat.S_ISREG(mode): + path.unlink() + # Unknown/symlink paths are not owned cleanup targets. + + def _reuse_checked_entry(self, entry, prepare, deadline, current_archive): + result = _prepare(entry[0], prepare, current_archive=current_archive) + if current_archive == entry[0].archive_sha256: + # A rollback recovery can leave the on-disk current pointer damaged. + # Metadata-only success must also leave a durable consistent pointer. + self._commit_state(entry[2], entry[2], deadline) + return result + + def _refresh(self, prepare, deadline, *, current_archive=None, selected_manifest=None): + _directory(self.cache_dir, create=True) + _directory(self.namespace, create=True) + _directory(self.namespace / "generations", create=True) + with _file_lock(self.namespace / ".lock", deadline) as waited: + with _file_lock(self.cache_dir / ".volume.lock", deadline): + entry = self._cached_entry() + if waited and entry and selected_manifest is None: + return self._reuse_checked_entry(entry, prepare, deadline, current_archive) + self._gc((entry[0].archive_sha256,) if entry else ()) + headers = {} + if entry: + for field, header in (("ETag", "If-None-Match"), ("Last-Modified", "If-Modified-Since")): + if value := entry[2]["validators"].get(field): + headers[header] = value + bootstrap = self.site_url + "ai/mcp/v1/manifest.json" + if selected_manifest is None: + status, raw, validators, final_url = self._transport( + bootstrap, self.site_url, headers, MAX_MANIFEST_BYTES, + deadline, self._read_seconds) + else: + status, raw, validators, final_url = 200, canonical_json(selected_manifest), {}, bootstrap + if status == 304 and entry is None: + status, raw, validators, final_url = self._transport( + bootstrap, self.site_url, {}, MAX_MANIFEST_BYTES, deadline, self._read_seconds) + if status == 304: + if entry is None: + raise LoaderError("http_status") + return self._reuse_checked_entry(entry, prepare, deadline, current_archive) + manifest = validate_manifest(raw) + archive_url, boundary = _archive_url(manifest, final_url, self.site_url) + digest = manifest["archive"]["sha256"] + old = entry[2] if entry else None + state = { + "schema_version": 1, "site_url": self.site_url, "active": digest, + "previous": (entry[2].get("previous") if digest == entry[0].archive_sha256 + else entry[0].archive_sha256) if entry else None, + "validators": validators, + } + try: + if entry and manifest == entry[1]: + # The complete validated manifest equals the one just + # verified against archive AND expanded cache bytes. + # Any changed field takes the existing strict path below; + # no format/metadata consistency rules are duplicated here. + reusable = entry[0] + else: + reusable, _ = self._generation(digest, manifest) + except (OSError, LoaderError): + reusable = None + except SnapshotError: + # A new manifest disagreeing with a verified current archive + # is invalid, rather than a reason to fetch that archive again. + if entry and entry[0].archive_sha256 == digest: + raise + reusable = None + if reusable is not None: + result = _prepare(reusable, prepare, current_archive=( + current_archive if entry and entry[0].archive_sha256 == digest else None)) + self._commit_state(state, old, deadline) + return result + self._space(manifest["archive"]["bytes"] + manifest["archive"]["unpacked_bytes"] + + len(raw) + 2 * MAX_MANIFEST_BYTES) + stage = Path(tempfile.mkdtemp(prefix=".stage-", dir=self.namespace)) + try: + self._write(stage / "manifest.json", raw, deadline) + status, archive, _, _ = self._transport( + archive_url, boundary, {}, manifest["archive"]["bytes"], + deadline, self._read_seconds, archive=True) + if status != 200: + raise LoaderError("http_status") + self._write(stage / "snapshot.tar.gz", archive, deadline) + snapshot = verify_archive(archive, manifest) + self._extract_verified(io.BytesIO(archive), snapshot, stage, deadline) + result = _prepare(snapshot, prepare) + _remaining(deadline) + _fsync_directory(stage) + target = self.namespace / "generations" / snapshot.archive_sha256 + if target.exists() or target.is_symlink(): + _directory(target) # Never replace a symlink/foreign path. + # A verified download repairs corrupt bytes under the same + # immutable identity. Keep the old directory until commit; + # a crash at either rename still permits previous fallback. + damaged = self.namespace / (".stage-" + os.urandom(12).hex()) + os.rename(target, damaged) + os.rename(stage, target) + _fsync_directory(target.parent) + self._commit_state(state, old, deadline) + try: + self._gc() + except (OSError, LoaderError, SnapshotError): + # Activation already succeeded. Leave garbage accounted + # in the volume budget for the next pre-attempt cleanup. + pass + return result + finally: + if stage.exists(): + self._remove_owned(stage) + + def _run(self, mode, prepare, stop=None, *, current_archive=None, selected_manifest=None): + deadline = time.monotonic() + self._attempt_seconds + stop = stop if stop is not None else threading.Event() + parent, child = socket.socketpair() + process = multiprocessing.get_context("spawn").Process( + target=_worker, args=(self, mode, prepare, deadline, child, current_archive, selected_manifest), + name="v8std-snapshot-worker", daemon=True) + started = False + try: + if stop.is_set(): + raise LoaderError("closed") + try: + process.start() + started = True + except Exception: + raise LoaderError("prepare_failed") from None + child.close() + parent.setblocking(False) + payload = bytearray() + length = None + while length is None or len(payload) < length: + if stop.is_set(): + raise LoaderError("closed") + remaining = _remaining(deadline) + if not select.select([parent], [], [], min(.025, remaining))[0]: + continue + chunk = parent.recv(_CHUNK if length is not None else 8 - len(payload)) + if not chunk: + raise LoaderError("worker_failed") + payload.extend(chunk) + if length is None and len(payload) == 8: + length = struct.unpack("!Q", payload)[0] + payload.clear() + process.join(min(.1, _remaining(deadline))) + # Local trusted IPC only. No filesystem or HTTP bytes reach loads. + kind, result, metadata = pickle.loads(payload) + _remaining(deadline) + if kind == "format_error": + raise SnapshotError(result) + if kind == "loader_error": + raise LoaderError(result) + return result, metadata + finally: + parent.close() + child.close() + if started: + if process.is_alive(): + process.terminate() + process.join(.3) + if process.is_alive(): + process.kill() + process.join(.3) + if not process.is_alive(): + process.join() + process.close() + + +def _cache_walk_error(): + raise LoaderError("cache_io") + + +def _worker(store, mode, prepare, deadline, channel, current_archive, selected_manifest=None): + try: + if mode == "cached": + snapshot = store.cached() + payload = _prepare(snapshot, prepare) if snapshot else pickle.dumps(("ok", None, None)) + else: + payload = store._refresh(prepare, deadline, current_archive=current_archive, + selected_manifest=selected_manifest) + _remaining(deadline) + except SnapshotError as error: + payload = pickle.dumps(("format_error", error.code, None)) + except LoaderError as error: + payload = pickle.dumps(("loader_error", error.code, None)) + except OSError: + payload = pickle.dumps(("loader_error", "cache_io", None)) + except Exception: + payload = pickle.dumps(("loader_error", "worker_failed", None)) + try: + channel.settimeout(max(.001, deadline - time.monotonic())) + channel.sendall(struct.pack("!Q", len(payload))) + channel.sendall(payload) + except OSError: + pass + finally: + channel.close() + + +class SnapshotCoordinator: + def __init__(self, store: SnapshotStore, build, *, refresh_seconds: int = 3600, + release_control: Path | None = None): + if type(refresh_seconds) is not int or refresh_seconds < 0: + raise LoaderError("configuration") + self.store = store + self.build = build + self.refresh_seconds = refresh_seconds + self._lock = threading.Lock() + self._stop = threading.Event() + self._thread = None + self._current = None + self._archive_sha256 = None + self._release_control = None + if release_control is not None: + from runtime.v8std_mcp_hold import ReleaseControl + self._release_control = ReleaseControl(self, Path(release_control)) + self._state = {"ready": False, "corpus_id": None, "loaded_at": None, + "archive_sha256": None, "corpus_source_sha": None, "hold_token": None, + "release_control_token": None, + "last_checked_at": None, "last_success_at": None, + "refresh_error_code": None} + + def start(self) -> None: + with self._lock: + if self._stop.is_set(): + raise LoaderError("closed") + if self._thread is None: + self._thread = threading.Thread(target=self._loop, name="v8std-snapshot-supervisor", daemon=True) + self._thread.start() + + def current(self): + with self._lock: + if not self._state["ready"]: + raise LoaderError("INDEX_NOT_READY") + return self._current + + def status(self) -> dict: + with self._lock: + return dict(self._state) + + def _accept(self, result, metadata, *, checked): + now = time.time() + retired = None + if self._release_control is not None: + self._release_control.pin(metadata["archive_sha256"]) + with self._lock: + if metadata.get("unchanged") and (not self._state["ready"] + or metadata["archive_sha256"] != self._archive_sha256 + or metadata["corpus_id"] != self._state["corpus_id"]): + raise LoaderError("worker_failed") + if metadata["archive_sha256"] != self._archive_sha256: + retired = self._current + self._current = result + self._archive_sha256 = metadata["archive_sha256"] + self._state.update(ready=True, corpus_id=metadata["corpus_id"], loaded_at=now, + archive_sha256=metadata["archive_sha256"], + corpus_source_sha=metadata.get("source_sha")) + if checked: + self._state.update(last_checked_at=now, last_success_at=now, refresh_error_code=None) + # Dropping a large generation's final reference can release thousands of + # objects. Even that CPU work belongs outside the query state lock. + del retired + + def _delay(self, failures): + if failures: + base = min(3600, 30 * 2 ** min(failures - 1, 7)) + return min(3600, max(30, base * random.uniform(.8, 1.2))) + return self.refresh_seconds * random.uniform(.8, 1.2) + + def _loop(self): + if self._release_control is not None: + self._release_control.run() + return + try: + result, metadata = self.store._run("cached", self.build, self._stop) + if metadata: + self._accept(result, metadata, checked=False) + del result # The active reference owns the accepted bootstrap result. + except (LoaderError, SnapshotError): + pass + failures = 0 + while not self._stop.is_set(): + try: + with self._lock: + current_archive = self._archive_sha256 if self._state["ready"] else None + result, metadata = self.store._run("refresh", self.build, self._stop, + current_archive=current_archive) + if self._stop.is_set(): + return + self._accept(result, metadata, checked=True) + # Same-hash candidates are not adopted. Drop the loop's reference + # outside the query lock, before sleeping for a refresh interval. + del result + failures = 0 + except (LoaderError, SnapshotError) as error: + if self._stop.is_set(): + return + failures += 1 + with self._lock: + self._state.update(last_checked_at=time.time(), refresh_error_code=error.code) + if self.refresh_seconds == 0 and self.status()["ready"]: + return + if self._stop.wait(self._delay(failures)): + return + + def close(self) -> None: + self._stop.set() + with self._lock: + thread = self._thread + if thread is not None: + thread.join(1.5) + if thread.is_alive(): + raise LoaderError("worker_failed") diff --git a/scripts/generate_search_vectors.py b/scripts/generate_search_vectors.py index c0dd479..1dced6a 100755 --- a/scripts/generate_search_vectors.py +++ b/scripts/generate_search_vectors.py @@ -7,18 +7,19 @@ import hashlib import json import math -import re import struct from pathlib import Path -from typing import Any +try: + from scripts.v8std_mcp_chunks import MAX_CHUNK_CHARS, page_chunks +except ModuleNotFoundError: # Direct script invocation outside the package route. + from v8std_mcp_chunks import MAX_CHUNK_CHARS, page_chunks from v8std_retrieval_rules import tokenize from atomic_files import atomic_write_text DEFAULT_DIM = 256 DEFAULT_MODEL = "v8std-hash-embeddings-v1" -MAX_CHUNK_CHARS = 2200 def signed_hash(value: str) -> tuple[int, float]: @@ -56,39 +57,6 @@ def encode_vector(vector: list[float]) -> str: return base64.b64encode(struct.pack(f"<{len(vector)}f", *vector)).decode("ascii") -def page_chunks(page: dict[str, Any]) -> list[tuple[str, int, str]]: - metadata = " ".join( - [ - page.get("id", ""), - page.get("title", ""), - page.get("description", ""), - " ".join(page.get("aliases", [])), - ] - ).strip() - chunks: list[tuple[str, int, str]] = [] - if metadata: - chunks.append(("metadata", 0, metadata)) - - body = page.get("body_markdown") or "" - paragraphs = [item.strip() for item in re.split(r"\n{2,}", body) if item.strip()] - current: list[str] = [] - current_len = 0 - chunk_index = 0 - for paragraph in paragraphs: - next_len = current_len + len(paragraph) + 2 - if current and next_len > MAX_CHUNK_CHARS: - chunks.append(("body", chunk_index, "\n\n".join(current))) - chunk_index += 1 - current = [] - current_len = 0 - current.append(paragraph) - current_len += len(paragraph) + 2 - if current: - chunks.append(("body", chunk_index, "\n\n".join(current))) - - return chunks - - def generate_rows(pages_path: Path, *, dim: int = DEFAULT_DIM, model: str = DEFAULT_MODEL) -> list[str]: rows: list[str] = [] with pages_path.open(encoding="utf-8") as file: diff --git a/scripts/publish_license_texts.py b/scripts/publish_license_texts.py index 1780244..57b9819 100644 --- a/scripts/publish_license_texts.py +++ b/scripts/publish_license_texts.py @@ -49,6 +49,14 @@ def publish_license_texts(repo_root: Path, site_dir: Path) -> set[str]: raise FileNotFoundError(f"canonical license text is missing: {source}") shutil.copyfile(source, target_dir / filename) + links = "\n".join(f'
  • {name}
  • ' + for name in sorted(LICENSE_FILENAMES)) + (target_dir / "index.html").write_text( + '' + '' + '' + 'Third-party license texts

    Third-party license texts

    ' + f'
      {links}
    \n', encoding="utf-8") return set(LICENSE_FILENAMES) diff --git a/scripts/run_v8std_mcp.sh b/scripts/run_v8std_mcp.sh deleted file mode 100644 index 4a2b01e..0000000 --- a/scripts/run_v8std_mcp.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="${V8STD_REPO_ROOT:-/docs}" -PAGES_PATH="${V8STD_MCP_PAGES:-${REPO_ROOT}/docs/ai/pages.jsonl}" -VECTORS_PATH="${V8STD_MCP_VECTORS:-${REPO_ROOT}/docs/ai/search-vectors.jsonl}" -GENERATE_INDEX="${V8STD_MCP_GENERATE_INDEX:-auto}" -MCP_HOST="${V8STD_MCP_HOST:-0.0.0.0}" -MCP_PORT="${V8STD_MCP_PORT:-8765}" -MCP_PATH="${V8STD_MCP_PATH:-/mcp}" -MCP_CACHE_DIR="${V8STD_MCP_CACHE_DIR:-/tmp/v8std-mcp}" - -export V8STD_REPO_ROOT="${REPO_ROOT}" -cd "${REPO_ROOT}" - -if [ "${GENERATE_INDEX}" = "always" ] || { - [ "${GENERATE_INDEX}" = "auto" ] && [ ! -s "${PAGES_PATH}" ] -}; then - python "${SCRIPT_DIR}/generate_social_cards.py" - python "${SCRIPT_DIR}/generate_ai_artifacts.py" - python "${SCRIPT_DIR}/generate_search_vectors.py" --pages "${PAGES_PATH}" --output "${VECTORS_PATH}" -elif [ ! -s "${VECTORS_PATH}" ]; then - python "${SCRIPT_DIR}/generate_search_vectors.py" --pages "${PAGES_PATH}" --output "${VECTORS_PATH}" -fi - -if [ ! -s "${PAGES_PATH}" ]; then - printf 'error: local MCP index file does not exist: %s\n' "${PAGES_PATH}" >&2 - printf 'hint: run with V8STD_MCP_GENERATE_INDEX=always or build the docs first.\n' >&2 - exit 1 -fi - -exec python "${SCRIPT_DIR}/v8std_mcp_server.py" \ - --pages "${PAGES_PATH}" \ - --vectors "${VECTORS_PATH}" \ - --cache-dir "${MCP_CACHE_DIR}" \ - --host "${MCP_HOST}" \ - --port "${MCP_PORT}" \ - --mcp-path "${MCP_PATH}" \ - --allowed-host "127.0.0.1:*" \ - --allowed-host "localhost:*" \ - --allowed-host "v8std-mcp:*" \ - --allowed-origin "http://127.0.0.1:*" \ - --allowed-origin "http://localhost:*" diff --git a/scripts/v8std_architecture.py b/scripts/v8std_architecture.py deleted file mode 100644 index 771e94f..0000000 --- a/scripts/v8std_architecture.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""Deterministic CLI for the v8std architecture process.""" - -from __future__ import annotations - -import argparse -import subprocess -import sys -from pathlib import Path -from typing import Sequence - -if __package__ in {None, ""}: - sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from scripts.v8std_architecture_model import ( # noqa: E402 - ArchitectureModelError, - build_graph, - discover_documents, - load_process_schema, -) -from scripts.v8std_architecture_validation import ( # noqa: E402 - ValidationIssue, - _load_base_documents, - compute_states, - find_impact_candidates, - validate_frozen_documents, - validate_graph, - validate_merge_readiness, -) - - -def _print_issues(issues: Sequence[ValidationIssue]) -> None: - for issue in sorted(set(issues)): - print(f"{issue.code} {issue.path}: {issue.message}") - - -def _load_graph(root: Path): - schema = load_process_schema(root) - graph = build_graph(discover_documents(root, schema)) - return schema, graph - - -def command_validate(args: argparse.Namespace) -> int: - root = Path(args.root).resolve() - try: - schema, graph = _load_graph(root) - except (ArchitectureModelError, OSError) as error: - _print_issues([ValidationIssue("MODEL_ERROR", ".", str(error))]) - return 1 - - issues = validate_graph(graph) - if args.base_ref: - issues.extend(validate_frozen_documents(root, args.base_ref, graph, schema)) - if args.merge_ready: - issues.extend(validate_merge_readiness(graph)) - issues = sorted(set(issues)) - _print_issues(issues) - return 1 if issues else 0 - - -def command_status(args: argparse.Namespace) -> int: - root = Path(args.root).resolve() - try: - schema, graph = _load_graph(root) - except (ArchitectureModelError, OSError) as error: - _print_issues([ValidationIssue("MODEL_ERROR", ".", str(error))]) - return 1 - - accepted_keys: frozenset[str] - if args.main_ref: - base_documents, issues = _load_base_documents(root, args.main_ref, schema) - if issues: - _print_issues(issues) - return 1 - accepted_keys = frozenset(base_documents).intersection(graph.documents) - else: - accepted_keys = frozenset(graph.documents) - for key, states in compute_states(graph, accepted_keys).items(): - print(f"{key}\t{','.join(sorted(states))}") - return 0 - - -def command_impact(args: argparse.Namespace) -> int: - root = Path(args.root).resolve() - try: - _, graph = _load_graph(root) - except (ArchitectureModelError, OSError) as error: - _print_issues([ValidationIssue("MODEL_ERROR", ".", str(error))]) - return 1 - - commands = ( - ["git", "diff", "--name-only", f"{args.base_ref}...HEAD"], - ["git", "diff", "--name-only", "HEAD"], - ["git", "ls-files", "--others", "--exclude-standard"], - ) - try: - results = [ - subprocess.run( - command, - cwd=root, - check=False, - capture_output=True, - text=True, - ) - for command in commands - ] - except FileNotFoundError: - _print_issues([ValidationIssue("GIT_UNAVAILABLE", ".", "git executable is unavailable")]) - return 1 - failed = next((result for result in results if result.returncode != 0), None) - if failed is not None: - _print_issues( - [ - ValidationIssue( - "BASE_REF_UNRESOLVED", - ".", - failed.stderr.strip() or f"cannot inspect changes from {args.base_ref}", - ) - ] - ) - return 1 - changed_paths = sorted( - { - line - for result in results - for line in result.stdout.splitlines() - if line - } - ) - for key, path in find_impact_candidates(graph, changed_paths): - print(f"{key}\t{path}") - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - validate_parser = subparsers.add_parser("validate", help="validate artifact graph") - validate_parser.add_argument("--root", default=".") - validate_parser.add_argument("--base-ref") - validate_parser.add_argument("--merge-ready", action="store_true") - validate_parser.set_defaults(handler=command_validate) - - status_parser = subparsers.add_parser("status", help="print computed artifact states") - status_parser.add_argument("--root", default=".") - status_parser.add_argument("--main-ref", default="main") - status_parser.set_defaults(handler=command_status) - - impact_parser = subparsers.add_parser("impact", help="list governed changed paths") - impact_parser.add_argument("--root", default=".") - impact_parser.add_argument("--base-ref", required=True) - impact_parser.set_defaults(handler=command_impact) - return parser - - -def main(argv: Sequence[str] | None = None) -> int: - args = build_parser().parse_args(argv) - return args.handler(args) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/v8std_architecture_model.py b/scripts/v8std_architecture_model.py deleted file mode 100644 index 710d55b..0000000 --- a/scripts/v8std_architecture_model.py +++ /dev/null @@ -1,452 +0,0 @@ -"""Structured Markdown model for v8std architecture artifacts.""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, field -from datetime import date -from pathlib import Path -from typing import Any, Iterable, Iterator - -import yaml - - -PROCESS_SCHEMA_PATH = Path("spec/process/architecture-artifacts-v1.md") -REFERENCE_RE = re.compile( - r"^(?Pdesign|adr|invariant|contract|plan|process):" - r"(?P[A-Za-z][A-Za-z0-9_-]*)" - r"(?:@(?P\d+)(?:\.(?P\d+))?)?$" -) -CHECKBOX_RE = re.compile(r"^\s*-\s+\[(?P[ xX])\]", re.MULTILINE) -REQUIREMENT_HEADING_RE = re.compile(r"^### ([A-Z][A-Z_]*)\s*$") -DATED_NAME_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)\.md$") - - -class ArchitectureModelError(ValueError): - """Raised when an artifact cannot be represented by the process model.""" - - -class _UniqueKeyLoader(yaml.SafeLoader): - pass - - -def _construct_unique_mapping( - loader: _UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False -) -> dict[object, object]: - mapping: dict[object, object] = {} - for key_node, value_node in node.value: - key = loader.construct_object(key_node, deep=deep) - if key in mapping: - raise ArchitectureModelError(f"duplicate YAML key: {key!r}") - mapping[key] = loader.construct_object(value_node, deep=deep) - return mapping - - -_UniqueKeyLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, - _construct_unique_mapping, -) - - -@dataclass(frozen=True) -class ArtifactRef: - kind: str - identity: str - version: int | None = None - revision: int | None = None - - @classmethod - def parse(cls, value: str) -> "ArtifactRef": - match = REFERENCE_RE.fullmatch(value) - if match is None: - raise ArchitectureModelError(f"invalid typed reference: {value!r}") - - kind = match.group("kind") - version_text = match.group("version") - revision_text = match.group("revision") - version = int(version_text) if version_text is not None else None - revision = int(revision_text) if revision_text is not None else None - - if kind == "contract": - if version is None or revision is None: - raise ArchitectureModelError( - f"contract reference requires @version.revision: {value!r}" - ) - elif kind == "process": - if version is None or revision is not None: - raise ArchitectureModelError( - f"process reference requires @version: {value!r}" - ) - elif version is not None: - raise ArchitectureModelError( - f"{kind} reference cannot contain a version: {value!r}" - ) - - return cls( - kind=kind, - identity=match.group("identity"), - version=version, - revision=revision, - ) - - def __str__(self) -> str: - value = f"{self.kind}:{self.identity}" - if self.kind == "contract": - return f"{value}@{self.version}.{self.revision}" - if self.kind == "process": - return f"{value}@{self.version}" - return value - - -@dataclass(frozen=True) -class ProcessSchema: - version: int - directories: dict[str, str] - semantic_id_pattern: re.Pattern[str] - document_id_pattern: re.Pattern[str] - typed_reference_pattern: re.Pattern[str] - contract_reference_pattern: re.Pattern[str] - adr_filename_pattern: re.Pattern[str] - frozen_kinds: frozenset[str] - forbidden_fields: frozenset[str] - - -@dataclass(frozen=True) -class ArchitectureDocument: - path: Path - kind: str - identity: str - scope: str | None - front_matter: dict[str, object] - body: str - references: tuple[ArtifactRef, ...] - created_on: date | None - checkbox_count: int - checked_count: int - requirement_definitions: tuple[str, ...] = () - - @property - def key(self) -> str: - if self.kind == "contract": - version = self.front_matter.get("version") - revision = self.front_matter.get("revision") - return f"contract:{self.identity}@{version if isinstance(version, int) else '?'}.{revision if isinstance(revision, int) else '?'}" - if self.kind == "process": - version = self.front_matter.get("version") - return f"process:{self.identity}@{version if isinstance(version, int) else '?'}" - return f"{self.kind}:{self.identity}" - - @property - def is_complete_plan(self) -> bool: - return ( - self.kind == "plan" - and self.checkbox_count > 0 - and self.checkbox_count == self.checked_count - ) - - -@dataclass(frozen=True) -class ArchitectureGraph: - documents: dict[str, ArchitectureDocument] - aliases: dict[str, str] - requirements: dict[str, str] - incoming: dict[str, tuple[str, ...]] - duplicate_keys: tuple[str, ...] = () - duplicate_aliases: tuple[str, ...] = () - duplicate_requirements: tuple[str, ...] = () - - -def _split_front_matter(text: str, path: Path) -> tuple[dict[str, object], str]: - if not text.startswith("---\n"): - raise ArchitectureModelError(f"{path}: missing YAML front matter") - delimiter = text.find("\n---\n", 4) - if delimiter < 0: - raise ArchitectureModelError(f"{path}: unterminated YAML front matter") - - payload_text = text[4:delimiter] - try: - payload = yaml.load(payload_text, Loader=_UniqueKeyLoader) - except ArchitectureModelError: - raise - except yaml.YAMLError as error: - raise ArchitectureModelError(f"{path}: malformed YAML front matter: {error}") from error - if not isinstance(payload, dict): - raise ArchitectureModelError(f"{path}: front matter must be a mapping") - if not all(isinstance(key, str) for key in payload): - raise ArchitectureModelError(f"{path}: front matter keys must be strings") - return payload, text[delimiter + 5 :] - - -def load_process_schema(repo_root: Path) -> ProcessSchema: - path = repo_root / PROCESS_SCHEMA_PATH - payload, _ = _split_front_matter(path.read_text(encoding="utf-8"), path) - schema = payload.get("schema") - if payload.get("kind") != "process" or not isinstance(schema, dict): - raise ArchitectureModelError(f"{path}: invalid process schema document") - - def compile_pattern(name: str) -> re.Pattern[str]: - value = schema.get(name) - if not isinstance(value, str): - raise ArchitectureModelError(f"{path}: schema.{name} must be a string") - return re.compile(value) - - directories = schema.get("directories") - frozen_kinds = schema.get("frozen_kinds") - forbidden_fields = schema.get("forbidden_fields") - if not isinstance(directories, dict) or not all( - isinstance(key, str) and isinstance(value, str) - for key, value in directories.items() - ): - raise ArchitectureModelError(f"{path}: schema.directories must map strings") - if not isinstance(frozen_kinds, list) or not all( - isinstance(value, str) for value in frozen_kinds - ): - raise ArchitectureModelError(f"{path}: schema.frozen_kinds must be a list") - if not isinstance(forbidden_fields, list) or not all( - isinstance(value, str) for value in forbidden_fields - ): - raise ArchitectureModelError(f"{path}: schema.forbidden_fields must be a list") - - version = payload.get("version") - if not isinstance(version, int): - raise ArchitectureModelError(f"{path}: process version must be an integer") - return ProcessSchema( - version=version, - directories=dict(directories), - semantic_id_pattern=compile_pattern("semantic_id_pattern"), - document_id_pattern=compile_pattern("document_id_pattern"), - typed_reference_pattern=compile_pattern("typed_reference_pattern"), - contract_reference_pattern=compile_pattern("contract_reference_pattern"), - adr_filename_pattern=compile_pattern("adr_filename_pattern"), - frozen_kinds=frozenset(frozen_kinds), - forbidden_fields=frozenset(forbidden_fields), - ) - - -def _iter_strings(value: object) -> Iterator[str]: - if isinstance(value, str): - yield value - elif isinstance(value, dict): - for nested in value.values(): - yield from _iter_strings(nested) - elif isinstance(value, list): - for nested in value: - yield from _iter_strings(nested) - - -def _extract_references(front_matter: dict[str, object]) -> tuple[ArtifactRef, ...]: - references: list[ArtifactRef] = [] - for value in _iter_strings(front_matter): - if re.match(r"^(design|adr|invariant|contract|plan|process):", value): - references.append(ArtifactRef.parse(value)) - return tuple(references) - - -def _extract_requirement_definitions(body: str) -> tuple[str, ...]: - definitions: list[str] = [] - in_requirements = False - for line in body.splitlines(): - if line == "## Требования": - in_requirements = True - continue - if line.startswith("## "): - in_requirements = False - if in_requirements: - match = REQUIREMENT_HEADING_RE.fullmatch(line) - if match is not None: - definitions.append(match.group(1)) - return tuple(definitions) - - -def _parse_created_on(filename: str, path: Path) -> date: - match = DATED_NAME_RE.fullmatch(filename) - if match is None: - raise ArchitectureModelError(f"{path}: filename must start with YYYY-MM-DD") - try: - return date.fromisoformat(match.group("date")) - except ValueError as error: - raise ArchitectureModelError(f"{path}: invalid Gregorian date") from error - - -def _semantic_slug(identity: str) -> str: - return identity.lower().replace("_", "-") - - -def _validate_identity(kind: str, identity: str, schema: ProcessSchema, path: Path) -> None: - pattern = ( - schema.semantic_id_pattern - if kind in {"adr", "invariant", "contract"} - else schema.document_id_pattern - ) - if pattern.fullmatch(identity) is None: - raise ArchitectureModelError(f"{path}: invalid {kind} id {identity!r}") - - -def _validate_filename( - path: Path, - kind: str, - identity: str, - payload: dict[str, object], - schema: ProcessSchema, -) -> date | None: - filename = path.name - created_on: date | None = None - if kind in {"design", "adr", "plan"}: - created_on = _parse_created_on(filename, path) - - if kind == "adr": - if schema.adr_filename_pattern.fullmatch(filename) is None: - raise ArchitectureModelError(f"{path}: invalid ADR filename") - expected_suffix = f"-{_semantic_slug(identity)}.md" - if not filename.endswith(expected_suffix): - raise ArchitectureModelError(f"{path}: filename does not match id {identity}") - elif kind == "design": - expected_suffix = f"-{identity}-design.md" - if not filename.endswith(expected_suffix): - raise ArchitectureModelError(f"{path}: filename does not match id {identity}") - elif kind == "plan": - expected_suffix = f"-{identity}-plan.md" - if not filename.endswith(expected_suffix): - raise ArchitectureModelError(f"{path}: filename does not match id {identity}") - elif kind == "invariant": - if filename != f"{_semantic_slug(identity)}.md": - raise ArchitectureModelError(f"{path}: filename does not match id {identity}") - elif kind == "contract": - version = payload.get("version") - revision = payload.get("revision") - if not isinstance(version, int) or not isinstance(revision, int): - raise ArchitectureModelError(f"{path}: contract version and revision must be integers") - expected = f"{_semantic_slug(identity)}-v{version}-r{revision}.md" - if filename != expected: - raise ArchitectureModelError(f"{path}: filename does not match id/version") - elif kind == "process": - version = payload.get("version") - if not isinstance(version, int): - raise ArchitectureModelError(f"{path}: process version must be an integer") - if filename != f"{identity}-v{version}.md": - raise ArchitectureModelError(f"{path}: filename does not match id/version") - return created_on - - -def load_document( - path: Path, - repo_root: Path, - schema: ProcessSchema, - *, - validate_path: bool = True, -) -> ArchitectureDocument: - try: - relative_path = path.resolve().relative_to(repo_root.resolve()) - except ValueError as error: - raise ArchitectureModelError(f"{path}: artifact is outside repository root") from error - - payload, body = _split_front_matter(path.read_text(encoding="utf-8"), relative_path) - forbidden = schema.forbidden_fields.intersection(payload) - if forbidden: - field_name = sorted(forbidden)[0] - raise ArchitectureModelError(f"{relative_path}: forbidden field {field_name!r}") - - schema_version = payload.get("schema_version") - kind = payload.get("kind") - identity = payload.get("id") - if schema_version != 1: - raise ArchitectureModelError(f"{relative_path}: schema_version must be 1") - if not isinstance(kind, str) or kind not in schema.directories: - raise ArchitectureModelError(f"{relative_path}: unknown artifact kind {kind!r}") - if not isinstance(identity, str): - raise ArchitectureModelError(f"{relative_path}: id must be a string") - _validate_identity(kind, identity, schema, relative_path) - - created_on: date | None = None - if validate_path: - expected_directory = Path(schema.directories[kind]) - if relative_path.parent != expected_directory: - raise ArchitectureModelError( - f"{relative_path}: {kind} must be stored in {expected_directory}" - ) - created_on = _validate_filename(relative_path, kind, identity, payload, schema) - elif kind in {"design", "adr", "plan"} and DATED_NAME_RE.fullmatch(path.name): - created_on = _parse_created_on(path.name, relative_path) - - scope = payload.get("scope") - if scope is not None and not isinstance(scope, str): - raise ArchitectureModelError(f"{relative_path}: scope must be a string") - checkboxes = CHECKBOX_RE.findall(body) - return ArchitectureDocument( - path=relative_path, - kind=kind, - identity=identity, - scope=scope, - front_matter=payload, - body=body, - references=_extract_references(payload), - created_on=created_on, - checkbox_count=len(checkboxes), - checked_count=sum(value.lower() == "x" for value in checkboxes), - requirement_definitions=_extract_requirement_definitions(body), - ) - - -def discover_documents( - repo_root: Path, schema: ProcessSchema -) -> list[ArchitectureDocument]: - paths: set[Path] = set() - for directory in schema.directories.values(): - artifact_directory = repo_root / directory - if artifact_directory.exists(): - paths.update( - path - for path in artifact_directory.glob("*.md") - if path.name != "README.md" - ) - return [load_document(path, repo_root, schema) for path in sorted(paths)] - - -def build_graph(documents: Iterable[ArchitectureDocument]) -> ArchitectureGraph: - document_map: dict[str, ArchitectureDocument] = {} - duplicate_keys: set[str] = set() - aliases: dict[str, str] = {} - duplicate_aliases: set[str] = set() - requirements: dict[str, str] = {} - duplicate_requirements: set[str] = set() - - for document in documents: - if document.key in document_map: - duplicate_keys.add(document.key) - else: - document_map[document.key] = document - - aliases_value = document.front_matter.get("aliases", []) - if isinstance(aliases_value, list): - for alias in aliases_value: - if not isinstance(alias, str): - continue - if alias in aliases and aliases[alias] != document.key: - duplicate_aliases.add(alias) - else: - aliases[alias] = document.key - - for requirement in document.requirement_definitions: - if requirement in requirements and requirements[requirement] != document.key: - duplicate_requirements.add(requirement) - else: - requirements[requirement] = document.key - - incoming_lists: dict[str, list[str]] = {key: [] for key in document_map} - for source_key, document in document_map.items(): - for reference in document.references: - target_key = str(reference) - if target_key in incoming_lists: - incoming_lists[target_key].append(source_key) - - return ArchitectureGraph( - documents=document_map, - aliases=aliases, - requirements=requirements, - incoming={ - key: tuple(sorted(values)) for key, values in sorted(incoming_lists.items()) - }, - duplicate_keys=tuple(sorted(duplicate_keys)), - duplicate_aliases=tuple(sorted(duplicate_aliases)), - duplicate_requirements=tuple(sorted(duplicate_requirements)), - ) diff --git a/scripts/v8std_architecture_validation.py b/scripts/v8std_architecture_validation.py deleted file mode 100644 index cc7326b..0000000 --- a/scripts/v8std_architecture_validation.py +++ /dev/null @@ -1,955 +0,0 @@ -"""Graph, lifecycle and merge-readiness rules for architecture artifacts.""" - -from __future__ import annotations - -import importlib.util -import subprocess -from collections import defaultdict -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable, Mapping - -from scripts.v8std_architecture_model import ( - ArchitectureDocument, - ArchitectureGraph, - ArchitectureModelError, - PROCESS_SCHEMA_PATH, - ProcessSchema, - _split_front_matter, -) - - -@dataclass(frozen=True, order=True) -class ValidationIssue: - code: str - path: str - message: str - - -REQUIRED_FIELDS: Mapping[str, frozenset[str]] = { - "design": frozenset( - { - "scope", - "requirements", - "decisions", - "invariants", - "contracts", - "supersedes", - "cancels", - } - ), - "adr": frozenset( - { - "scope", - "design", - "requirements", - "aliases", - "supersedes", - "cancels", - "invariants", - "contracts", - } - ), - "invariant": frozenset({"scope", "introduced_by", "requirements", "check"}), - "contract": frozenset( - { - "scope", - "version", - "revision", - "compatibility", - "design", - "producer", - "consumers", - "requirements", - "governs", - "conformance", - "supersedes", - "deprecates", - } - ), - "plan": frozenset({"design", "implements"}), - "process": frozenset({"version", "schema"}), -} - -REFERENCE_FIELDS: Mapping[str, Mapping[str, frozenset[str]]] = { - "design": { - "decisions": frozenset({"adr"}), - "invariants": frozenset({"invariant"}), - "contracts": frozenset({"contract"}), - "supersedes": frozenset({"design"}), - "cancels": frozenset({"design"}), - }, - "adr": { - "design": frozenset({"design"}), - "supersedes": frozenset({"adr"}), - "cancels": frozenset({"adr"}), - "invariants": frozenset({"invariant"}), - "contracts": frozenset({"contract"}), - }, - "invariant": {"introduced_by": frozenset({"adr"})}, - "contract": { - "design": frozenset({"design"}), - "supersedes": frozenset({"contract"}), - "deprecates": frozenset({"contract"}), - }, - "plan": { - "design": frozenset({"design"}), - "implements": frozenset({"design", "adr", "invariant", "contract", "process"}), - }, - "process": {}, -} - -BOOTSTRAP_ADR_ALIASES: Mapping[str, str] = { - "ADR-0001": "MCP_VERSION_ENDPOINT_ISOLATION", - "ADR-0002": "PUBLIC_MCP_MONITORING", - "ADR-0003": "LOCAL_OPENMETRICS_EXPOSITION", - "ADR-0004": "PAGE_READING_VIA_RESOURCES", -} - - -def _issue(document: ArchitectureDocument, code: str, message: str) -> ValidationIssue: - return ValidationIssue(code, document.path.as_posix(), message) - - -def _values(value: object) -> Iterable[object]: - if isinstance(value, dict): - for key, nested in value.items(): - yield key - yield from _values(nested) - elif isinstance(value, list): - for nested in value: - yield from _values(nested) - else: - yield value - - -def _typed_values(value: object) -> Iterable[str]: - for nested in _values(value): - if isinstance(nested, str) and nested.startswith( - ("design:", "adr:", "invariant:", "contract:", "plan:", "process:") - ): - yield nested - - -def _string_list(value: object) -> list[str]: - if not isinstance(value, list): - return [] - return [item for item in value if isinstance(item, str)] - - -def _mapping(value: object) -> dict[str, object]: - if not isinstance(value, dict): - return {} - return {str(key): nested for key, nested in value.items()} - - -def _reference_key(value: str) -> str: - return value - - -def validate_references(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - for key in graph.duplicate_keys: - document = graph.documents[key] - issues.append(_issue(document, "DUPLICATE_IDENTITY", f"duplicate canonical identity {key}")) - for alias in graph.duplicate_aliases: - issues.append(ValidationIssue("DUPLICATE_ALIAS", "spec", f"duplicate historical alias {alias}")) - for requirement in graph.duplicate_requirements: - issues.append( - ValidationIssue( - "DUPLICATE_REQUIREMENT", - "spec", - f"requirement {requirement} has multiple definitions", - ) - ) - - for document in graph.documents.values(): - for field in sorted(REQUIRED_FIELDS.get(document.kind, frozenset())): - if field not in document.front_matter: - issues.append(_issue(document, "MISSING_REQUIRED_FIELD", f"missing required field {field}")) - - for reference in document.references: - key = str(reference) - if reference.kind == "adr" and reference.identity in graph.aliases: - issues.append( - _issue( - document, - "HISTORICAL_ALIAS_REFERENCE", - f"current front matter uses historical alias {reference.identity}", - ) - ) - continue - if key not in graph.documents: - issues.append(_issue(document, "DANGLING_REFERENCE", f"reference {key} does not resolve")) - - for field, allowed_kinds in REFERENCE_FIELDS.get(document.kind, {}).items(): - value = document.front_matter.get(field) - for reference_text in _typed_values(value): - reference_kind = reference_text.split(":", 1)[0] - if reference_kind not in allowed_kinds: - issues.append( - _issue( - document, - "INVALID_REFERENCE_TYPE", - f"field {field} cannot reference {reference_text}", - ) - ) - return issues - - -def validate_scopes(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - allowed_scopes = { - "design": {"product", "process"}, - "adr": {"product"}, - "invariant": {"product"}, - "contract": {"product"}, - } - for document in graph.documents.values(): - allowed = allowed_scopes.get(document.kind) - if allowed is not None and document.scope not in allowed: - issues.append( - _issue( - document, - "INVALID_SCOPE", - f"{document.kind} scope must be one of: {', '.join(sorted(allowed))}", - ) - ) - return issues - - -def _requirement_fields(document: ArchitectureDocument) -> list[str]: - value = document.front_matter.get("requirements") - if document.kind == "design": - requirements = _mapping(value) - result = _string_list(requirements.get("uses")) - replaces = _mapping(requirements.get("replaces")) - result.extend(replaces) - result.extend(value for value in replaces.values() if isinstance(value, str)) - result.extend(_string_list(requirements.get("cancels"))) - return result - return _string_list(value) - - -def _active_requirement_fields(document: ArchitectureDocument) -> list[str]: - value = document.front_matter.get("requirements") - if document.kind != "design": - return _string_list(value) - - requirements = _mapping(value) - result = _string_list(requirements.get("uses")) - result.extend( - value - for value in _mapping(requirements.get("replaces")).values() - if isinstance(value, str) - ) - return result - - -def _requirement_lifecycle(graph: ArchitectureGraph) -> tuple[set[str], dict[str, str]]: - cancelled: set[str] = set() - replaced: dict[str, str] = {} - for document in graph.documents.values(): - if document.kind != "design": - continue - requirements = _mapping(document.front_matter.get("requirements")) - cancelled.update(_string_list(requirements.get("cancels"))) - for old, new in _mapping(requirements.get("replaces")).items(): - if isinstance(new, str): - replaced[old] = new - return cancelled, replaced - - -def _has_mapping_cycle(edges: Mapping[str, str]) -> bool: - for origin in edges: - seen: set[str] = set() - current = origin - while current in edges: - if current in seen: - return True - seen.add(current) - current = edges[current] - return False - - -def validate_requirements(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - cancelled, replaced = _requirement_lifecycle(graph) - if _has_mapping_cycle(replaced): - issues.append( - ValidationIssue( - "RELATION_CYCLE", - "spec/designs", - "requirement replacement graph contains a cycle", - ) - ) - - process_requirements = { - code - for code, owner in graph.requirements.items() - if graph.documents.get(owner) is not None and graph.documents[owner].scope == "process" - } - states = compute_states(graph, frozenset(graph.documents)) - terminal_states = {"SUPERSEDED", "CANCELLED", "DEPRECATED", "RETIRED"} - - for document in graph.documents.values(): - if document.kind == "design": - requirements = _mapping(document.front_matter.get("requirements")) - introduced = set(_string_list(requirements.get("introduces"))) - defined = set(document.requirement_definitions) - for code in sorted(introduced - defined): - issues.append( - _issue(document, "MISSING_REQUIREMENT_DEFINITION", f"{code} has no ### definition") - ) - for code in sorted(defined - introduced): - issues.append( - _issue(document, "UNDECLARED_REQUIREMENT", f"{code} is not listed in introduces") - ) - - for code in _requirement_fields(document): - if code not in graph.requirements: - issues.append(_issue(document, "UNDEFINED_REQUIREMENT", f"requirement {code} is undefined")) - continue - if document.scope == "product" and code in process_requirements: - issues.append( - _issue( - document, - "PROCESS_REQUIREMENT_USED_BY_PRODUCT", - f"product architecture cannot use process requirement {code}", - ) - ) - - if terminal_states.intersection(states[document.key]): - continue - for code in _active_requirement_fields(document): - if code in cancelled: - issues.append( - _issue( - document, - "CANCELLED_REQUIREMENT", - f"requirement {code} is cancelled", - ) - ) - elif code in replaced: - issues.append( - _issue( - document, - "REPLACED_REQUIREMENT", - f"requirement {code} is replaced by {replaced[code]}", - ) - ) - - for successor in graph.documents.values(): - if successor.kind != "design": - continue - lifecycle = _mapping(successor.front_matter.get("requirements")) - handled = set(_string_list(lifecycle.get("uses"))) - handled.update(_mapping(lifecycle.get("replaces"))) - handled.update(_string_list(lifecycle.get("cancels"))) - for predecessor_ref in _string_list(successor.front_matter.get("supersedes")): - predecessor = graph.documents.get(predecessor_ref) - if predecessor is None or predecessor.kind != "design": - continue - predecessor_requirements = _mapping(predecessor.front_matter.get("requirements")) - for code in _string_list(predecessor_requirements.get("introduces")): - if code not in handled: - issues.append( - _issue( - successor, - "DROPPED_REQUIREMENT", - f"superseded requirement {code} is not preserved, replaced or cancelled", - ) - ) - return issues - - -def _relation_values(document: ArchitectureDocument, field: str) -> set[str]: - return set(_string_list(document.front_matter.get(field))) - - -def _relation_cycle(graph: ArchitectureGraph, fields: tuple[str, ...]) -> set[str]: - edges: dict[str, set[str]] = defaultdict(set) - for document in graph.documents.values(): - for field in fields: - for target in _relation_values(document, field): - if target in graph.documents: - edges[document.key].add(target) - - involved: set[str] = set() - visiting: set[str] = set() - visited: set[str] = set() - - def visit(node: str, trail: list[str]) -> None: - if node in visiting: - start = trail.index(node) - involved.update(trail[start:]) - return - if node in visited: - return - visiting.add(node) - trail.append(node) - for target in edges.get(node, set()): - visit(target, trail) - trail.pop() - visiting.remove(node) - visited.add(node) - - for node in edges: - visit(node, []) - return involved - - -def validate_adr_relations(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - for key in sorted(_relation_cycle(graph, ("supersedes", "cancels"))): - issues.append(_issue(graph.documents[key], "RELATION_CYCLE", "artifact lifecycle contains a cycle")) - - successors: dict[str, list[ArchitectureDocument]] = defaultdict(list) - for document in graph.documents.values(): - supersedes = _relation_values(document, "supersedes") - cancels = _relation_values(document, "cancels") - for predecessor in supersedes: - successors[predecessor].append(document) - for target in sorted(supersedes.intersection(cancels)): - issues.append( - _issue( - document, - "MIXED_CANCEL_AND_SUPERSEDE", - f"{target} is both cancelled and superseded", - ) - ) - - for predecessor, replacements in successors.items(): - adr_replacements = [document for document in replacements if document.kind == "adr"] - if len(adr_replacements) < 2: - continue - designs = { - document.front_matter.get("design") - for document in adr_replacements - if isinstance(document.front_matter.get("design"), str) - } - if len(designs) > 1: - for document in adr_replacements: - issues.append( - _issue( - document, - "COMPOSITE_REPLACEMENT_SPLIT", - f"replacement of {predecessor} is split between designs", - ) - ) - return issues - - -def _current_impact_targets(document: ArchitectureDocument, field: str) -> set[str]: - impact = _mapping(document.front_matter.get(field)) - result = set(_string_list(impact.get("introduces"))) - result.update(_string_list(impact.get("preserves"))) - result.update( - value - for value in _mapping(impact.get("replaces")).values() - if isinstance(value, str) - ) - return result - - -def _disposed_impact_targets(document: ArchitectureDocument, field: str) -> set[str]: - impact = _mapping(document.front_matter.get(field)) - result = set(_string_list(impact.get("preserves"))) - result.update(_mapping(impact.get("replaces"))) - result.update(_string_list(impact.get("cancels"))) - return result - - -def validate_adrs(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - successors: dict[str, list[ArchitectureDocument]] = defaultdict(list) - - for document in graph.documents.values(): - if document.kind != "adr": - continue - - requirements = document.front_matter.get("requirements") - if not isinstance(requirements, list) or not requirements or not all( - isinstance(value, str) for value in requirements - ): - issues.append( - _issue( - document, - "ADR_WITHOUT_REQUIREMENTS", - "ADR requires at least one input requirement", - ) - ) - - aliases = document.front_matter.get("aliases") - if not isinstance(aliases, list) or not all( - isinstance(alias, str) - and BOOTSTRAP_ADR_ALIASES.get(alias) == document.identity - for alias in aliases - ): - issues.append( - _issue( - document, - "INVALID_ADR_ALIAS", - "only the fixed ADR-0001 through ADR-0004 bootstrap aliases are allowed", - ) - ) - - for field in ("invariants", "contracts"): - impact = document.front_matter.get(field) - if not isinstance(impact, dict) or set(impact) != { - "introduces", - "preserves", - "replaces", - "cancels", - }: - issues.append( - _issue( - document, - "INCOMPLETE_ADR_IMPACT", - f"ADR {field} impact must declare introduces, preserves, replaces and cancels", - ) - ) - - for predecessor in ( - _string_list(document.front_matter.get("supersedes")) - + _string_list(document.front_matter.get("cancels")) - ): - target = graph.documents.get(predecessor) - if target is not None and target.kind == "adr": - successors[predecessor].append(document) - - for predecessor_key, replacements in successors.items(): - predecessor = graph.documents[predecessor_key] - replacement = sorted(replacements, key=lambda document: document.path.as_posix())[0] - for field, issue_code in ( - ("invariants", "UNDISPOSED_INVARIANT"), - ("contracts", "UNDISPOSED_CONTRACT"), - ): - affected = _current_impact_targets(predecessor, field) - disposed: set[str] = set() - for successor in replacements: - disposed.update(_disposed_impact_targets(successor, field)) - for target in sorted(affected - disposed): - issues.append( - _issue( - replacement, - issue_code, - f"replacement of {predecessor_key} does not dispose {target}", - ) - ) - return issues - - -def _check_declaration_present(value: object) -> bool: - if isinstance(value, str): - return bool(value.strip()) - if isinstance(value, dict): - module = value.get("module") - command = value.get("command") - return bool( - (isinstance(module, str) and module.strip()) - or (isinstance(command, str) and command.strip()) - ) - return False - - -def _validate_fitness_timing(document: ArchitectureDocument) -> list[ValidationIssue]: - required_when = document.front_matter.get("required_when", "accepted") - if required_when in {"accepted", "implemented"}: - return [] - return [ - _issue( - document, - "INVALID_FITNESS_TIMING", - "required_when must be accepted or implemented", - ) - ] - - -def validate_invariants(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - cancelled_requirements, _ = _requirement_lifecycle(graph) - for document in graph.documents.values(): - if document.kind != "invariant": - continue - basis = document.front_matter.get("introduced_by") - requirements = _string_list(document.front_matter.get("requirements")) - if not isinstance(basis, str) or not basis.startswith("adr:") or not requirements: - issues.append( - _issue( - document, - "INVARIANT_WITHOUT_BASIS", - "invariant requires an introducing ADR and at least one requirement", - ) - ) - if not _check_declaration_present(document.front_matter.get("check")): - issues.append(_issue(document, "INVARIANT_WITHOUT_CHECK", "invariant has no check declaration")) - issues.extend(_validate_fitness_timing(document)) - - for decision in graph.documents.values(): - if decision.kind != "adr": - continue - impact = _mapping(decision.front_matter.get("invariants")) - for target in _string_list(impact.get("cancels")): - invariant = graph.documents.get(target) - if invariant is None or invariant.kind != "invariant": - continue - active = [ - code - for code in _string_list(invariant.front_matter.get("requirements")) - if code not in cancelled_requirements - ] - if active: - issues.append( - _issue( - decision, - "PREMATURE_RETIREMENT", - f"{target} is retired while requirements remain active: {', '.join(active)}", - ) - ) - return issues - - -def validate_contracts(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - for document in graph.documents.values(): - if document.kind != "contract": - continue - version = document.front_matter.get("version") - revision = document.front_matter.get("revision") - if not isinstance(version, int) or version < 1: - issues.append(_issue(document, "CONTRACT_WITHOUT_VERSION", "contract version must be positive")) - if not isinstance(revision, int) or revision < 0: - issues.append(_issue(document, "INVALID_CONTRACT_REVISION", "contract revision must be non-negative")) - compatibility = document.front_matter.get("compatibility") - if compatibility not in {"backward-compatible", "breaking"}: - issues.append(_issue(document, "INVALID_COMPATIBILITY", "invalid contract compatibility")) - producer = document.front_matter.get("producer") - if not isinstance(producer, str) or not producer.strip(): - issues.append(_issue(document, "CONTRACT_WITHOUT_PRODUCER", "contract producer is empty")) - if not _string_list(document.front_matter.get("consumers")): - issues.append(_issue(document, "CONTRACT_WITHOUT_CONSUMERS", "contract consumers are empty")) - if not _check_declaration_present(document.front_matter.get("conformance")): - issues.append(_issue(document, "CONTRACT_WITHOUT_CONFORMANCE", "contract has no conformance declaration")) - issues.extend(_validate_fitness_timing(document)) - return issues - - -def validate_plans(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - for document in graph.documents.values(): - if document.kind == "plan" and document.checkbox_count == 0: - issues.append(_issue(document, "PLAN_WITHOUT_CHECKBOX", "plan must contain a checkbox")) - return issues - - -def validate_graph(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - issues.extend(validate_references(graph)) - issues.extend(validate_scopes(graph)) - issues.extend(validate_requirements(graph)) - issues.extend(validate_adr_relations(graph)) - issues.extend(validate_adrs(graph)) - issues.extend(validate_invariants(graph)) - issues.extend(validate_contracts(graph)) - issues.extend(validate_plans(graph)) - return sorted(set(issues)) - - -def _declared_module(document: ArchitectureDocument) -> str | None: - field_name = "check" if document.kind == "invariant" else "conformance" - declaration = document.front_matter.get(field_name) - if isinstance(declaration, dict): - module = declaration.get("module") - if isinstance(module, str) and module: - return module - return None - - -def _module_exists(module: str) -> bool: - try: - return importlib.util.find_spec(module) is not None - except (ImportError, ModuleNotFoundError, ValueError): - return False - - -def validate_merge_readiness(graph: ArchitectureGraph) -> list[ValidationIssue]: - issues: list[ValidationIssue] = [] - for document in graph.documents.values(): - if document.kind == "plan" and not document.is_complete_plan: - issues.append(_issue(document, "INCOMPLETE_PLAN", "plan is not complete")) - - accepted_keys = frozenset(graph.documents) - states = compute_states(graph, accepted_keys) - for document in graph.documents.values(): - if document.kind not in {"invariant", "contract"}: - continue - required_when = document.front_matter.get("required_when", "accepted") - must_exist = required_when == "accepted" or ( - required_when == "implemented" and "IMPLEMENTED" in states[document.key] - ) - module = _declared_module(document) - if must_exist and (module is None or not _module_exists(module)): - issues.append( - _issue( - document, - "MISSING_FITNESS_EVIDENCE", - f"required Python test module is unavailable: {module or ''}", - ) - ) - return sorted(set(issues)) - - -def _impact_targets(document: ArchitectureDocument, field: str) -> set[str]: - impact = _mapping(document.front_matter.get(field)) - result = set(_string_list(impact.get("cancels"))) - result.update(_mapping(impact.get("replaces"))) - return result - - -def compute_states( - graph: ArchitectureGraph, accepted_keys: frozenset[str] -) -> dict[str, frozenset[str]]: - states: dict[str, set[str]] = { - key: ({"ACCEPTED"} if key in accepted_keys else {"CANDIDATE"}) - for key in graph.documents - } - - for document in graph.documents.values(): - if document.key not in accepted_keys: - continue - for target in _string_list(document.front_matter.get("supersedes")): - if target in states: - states[target].discard("ACCEPTED") - states[target].add("SUPERSEDED") - for target in _string_list(document.front_matter.get("cancels")): - if target in states: - states[target].discard("ACCEPTED") - states[target].add("CANCELLED") - if document.kind == "adr": - for target in _impact_targets(document, "invariants"): - if target in states: - states[target].discard("ACCEPTED") - states[target].add("RETIRED") - for target in _impact_targets(document, "contracts"): - if target in states: - states[target].discard("ACCEPTED") - states[target].add("DEPRECATED") - if document.kind == "contract": - for target in _string_list(document.front_matter.get("deprecates")): - if target in states: - states[target].discard("ACCEPTED") - states[target].add("DEPRECATED") - - for document in graph.documents.values(): - if ( - document.kind != "plan" - or document.key not in accepted_keys - or not document.is_complete_plan - ): - continue - for target in _string_list(document.front_matter.get("implements")): - if target in states and target in accepted_keys: - states[target].add("IMPLEMENTED") - - return {key: frozenset(value) for key, value in sorted(states.items())} - - -@dataclass(frozen=True) -class _BaseDocument: - key: str - path: str - content: bytes - - -def _run_git( - repo_root: Path, arguments: list[str], *, text: bool = True -) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", *arguments], - cwd=repo_root, - check=False, - capture_output=True, - text=text, - ) - - -def _base_key(payload: dict[str, object], schema: ProcessSchema) -> str | None: - kind = payload.get("kind") - identity = payload.get("id") - if ( - payload.get("schema_version") != 1 - or not isinstance(kind, str) - or kind not in schema.frozen_kinds - or not isinstance(identity, str) - or schema.forbidden_fields.intersection(payload) - or not REQUIRED_FIELDS.get(kind, frozenset()).issubset(payload) - ): - return None - identity_pattern = ( - schema.semantic_id_pattern - if kind in {"adr", "invariant", "contract"} - else schema.document_id_pattern - ) - if identity_pattern.fullmatch(identity) is None: - return None - if kind == "contract": - version = payload.get("version") - revision = payload.get("revision") - if not isinstance(version, int) or not isinstance(revision, int): - return None - return f"contract:{identity}@{version}.{revision}" - if kind == "process": - version = payload.get("version") - if not isinstance(version, int): - return None - return f"process:{identity}@{version}" - return f"{kind}:{identity}" - - -def _load_base_documents( - repo_root: Path, base_ref: str, schema: ProcessSchema -) -> tuple[dict[str, _BaseDocument], list[ValidationIssue]]: - try: - resolved = _run_git(repo_root, ["rev-parse", "--verify", f"{base_ref}^{{commit}}"]) - except FileNotFoundError: - return {}, [ValidationIssue("GIT_UNAVAILABLE", ".", "git executable is unavailable")] - if resolved.returncode != 0: - return {}, [ - ValidationIssue( - "BASE_REF_UNRESOLVED", - ".", - f"cannot resolve requested base ref {base_ref}", - ) - ] - - listing = _run_git(repo_root, ["ls-tree", "-r", "--name-only", base_ref, "--", "spec"]) - if listing.returncode != 0: - return {}, [ - ValidationIssue("GIT_READ_FAILED", "spec", listing.stderr.strip() or "git ls-tree failed") - ] - - documents: dict[str, _BaseDocument] = {} - for path_text in sorted(line for line in listing.stdout.splitlines() if line.endswith(".md")): - shown = _run_git(repo_root, ["show", f"{base_ref}:{path_text}"], text=False) - if shown.returncode != 0: - return {}, [ - ValidationIssue( - "GIT_READ_FAILED", - path_text, - shown.stderr.decode("utf-8", errors="replace").strip() - or "git show failed", - ) - ] - content = shown.stdout - try: - text = content.decode("utf-8") - payload, _ = _split_front_matter(text, Path(path_text)) - except (UnicodeDecodeError, ArchitectureModelError): - continue - key = _base_key(payload, schema) - if key is not None: - documents[key] = _BaseDocument(key=key, path=path_text, content=content) - return documents, [] - - -def validate_frozen_documents( - repo_root: Path, - base_ref: str, - graph: ArchitectureGraph, - schema: ProcessSchema | None = None, -) -> list[ValidationIssue]: - if schema is None: - from scripts.v8std_architecture_model import load_process_schema - - schema = load_process_schema(repo_root) - base_documents, issues = _load_base_documents(repo_root, base_ref, schema) - if issues: - return issues - - current_by_key = graph.documents - freeze_issues: list[ValidationIssue] = [] - base_process = _run_git( - repo_root, - ["show", f"{base_ref}:{PROCESS_SCHEMA_PATH.as_posix()}"], - text=False, - ) - if base_process.returncode == 0: - process_path = repo_root / PROCESS_SCHEMA_PATH - try: - current_process = process_path.read_bytes() - except OSError as error: - freeze_issues.append( - ValidationIssue( - "FROZEN_DOCUMENT_DELETED", - PROCESS_SCHEMA_PATH.as_posix(), - str(error), - ) - ) - else: - if current_process != base_process.stdout: - freeze_issues.append( - ValidationIssue( - "FROZEN_DOCUMENT_MODIFIED", - PROCESS_SCHEMA_PATH.as_posix(), - "frozen process schema content changed", - ) - ) - for key, base_document in sorted(base_documents.items()): - current = current_by_key.get(key) - if current is None: - freeze_issues.append( - ValidationIssue( - "FROZEN_DOCUMENT_DELETED", - base_document.path, - f"frozen document {key} was deleted", - ) - ) - continue - current_path = current.path.as_posix() - if current_path != base_document.path: - freeze_issues.append( - ValidationIssue( - "FROZEN_DOCUMENT_MOVED", - base_document.path, - f"frozen document {key} moved to {current_path}", - ) - ) - continue - try: - current_content = (repo_root / current.path).read_bytes() - except OSError as error: - freeze_issues.append( - ValidationIssue("FROZEN_DOCUMENT_DELETED", current_path, str(error)) - ) - continue - if current_content != base_document.content: - freeze_issues.append( - ValidationIssue( - "FROZEN_DOCUMENT_MODIFIED", - current_path, - f"frozen document {key} content changed", - ) - ) - return sorted(set(freeze_issues)) - - -def find_impact_candidates( - graph: ArchitectureGraph, changed_paths: Iterable[str] -) -> list[tuple[str, str]]: - normalized_paths = {Path(path).as_posix() for path in changed_paths} - candidates: set[tuple[str, str]] = set() - for document in graph.documents.values(): - if document.kind not in {"contract", "invariant"}: - continue - governed_paths = _string_list(document.front_matter.get("governs")) - for governed in governed_paths: - matches = ( - any(path.startswith(governed) for path in normalized_paths) - if governed.endswith("/") - else governed in normalized_paths - ) - if matches: - candidates.add((document.key, document.path.as_posix())) - break - return sorted(candidates) diff --git a/scripts/v8std_mcp_chunks.py b/scripts/v8std_mcp_chunks.py new file mode 100644 index 0000000..a6a01db --- /dev/null +++ b/scripts/v8std_mcp_chunks.py @@ -0,0 +1,42 @@ +"""Shared canonical chunk rules for vector generation and snapshot verification.""" + +from __future__ import annotations + +import re +from typing import Any + + +MAX_CHUNK_CHARS = 2200 + + +def page_chunks(page: dict[str, Any]) -> list[tuple[str, int, str]]: + metadata = " ".join( + [ + page.get("id", ""), + page.get("title", ""), + page.get("description", ""), + " ".join(page.get("aliases", [])), + ] + ).strip() + chunks: list[tuple[str, int, str]] = [] + if metadata: + chunks.append(("metadata", 0, metadata)) + + body = page.get("body_markdown") or "" + paragraphs = [item.strip() for item in re.split(r"\n{2,}", body) if item.strip()] + current: list[str] = [] + current_len = 0 + chunk_index = 0 + for paragraph in paragraphs: + next_len = current_len + len(paragraph) + 2 + if current and next_len > MAX_CHUNK_CHARS: + chunks.append(("body", chunk_index, "\n\n".join(current))) + chunk_index += 1 + current = [] + current_len = 0 + current.append(paragraph) + current_len += len(paragraph) + 2 + if current: + chunks.append(("body", chunk_index, "\n\n".join(current))) + + return chunks diff --git a/scripts/v8std_mcp_monitoring.py b/scripts/v8std_mcp_monitoring.py deleted file mode 100644 index 5bdf6ac..0000000 --- a/scripts/v8std_mcp_monitoring.py +++ /dev/null @@ -1,1075 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import argparse -import gzip -import html -import json -import shlex -import subprocess -from collections import Counter -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Iterable, Iterator - - -DEFAULT_ACCESS_LOG = Path("/var/log/nginx/ai.v8std.ru.access.log") -DEFAULT_USAGE_LOG = Path("/var/lib/v8std-mcp/tool-usage.jsonl") -DEFAULT_OUTPUT_DIR = Path("/var/www/ai.v8std.ru-monitoring") -DEFAULT_SERVICE = "v8std-mcp.service" -DEFAULT_WINDOW_HOURS = 24 -TOP_RANKING_LIMIT = 50 -RECENT_SEARCH_LIMIT = 10 -MAX_SEARCH_RESULTS_PER_QUERY = 50 - -SYSTEM_LABELS = { - "codex": "Codex", - "claude": "Claude", - "cursor": "Cursor", - "jetbrains": "JetBrains", - "vscode": "VS Code", - "monitoring": "Monitoring", - "curl": "curl", - "browser": "Browser", - "node": "Node", - "opencode": "opencode", - "go": "Go", - "python_httpx": "Python httpx", - "kilo": "Kilo", - "java": "Java", - "unknown": "Unknown", - "other": "Other", -} -UNIDENTIFIED_CLIENT_SYSTEMS = {"unknown", "other"} - -TOOL_LABELS = { - "v8std_search": "v8std_search", - "v8std_get_page": "v8std_get_page", - "v8std_get_related": "v8std_get_related", - "v8std_explain_snippet": "v8std_explain_snippet", - "v8std_explain_diagnostics": "v8std_explain_diagnostics", -} - -IGNORED_NON_MCP_PATHS = {"/", "/healthz", "/version", "/monitoring"} - -def parse_log_line(line: str) -> dict[str, str] | None: - try: - parts = shlex.split(line) - except ValueError: - return None - - fields: dict[str, str] = {} - for part in parts: - if "=" not in part: - continue - key, value = part.split("=", 1) - fields[key] = value - return fields if "ts" in fields else None - - -def parse_iso_datetime(value: str) -> datetime | None: - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - return parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - - -def is_mcp_request(uri: str) -> bool: - return uri.split("?", 1)[0] in {"/mcp", "/mcp/"} - - -def is_ignored_non_mcp_request(uri: str) -> bool: - path = uri.split("?", 1)[0] - return path in IGNORED_NON_MCP_PATHS or path.startswith("/monitoring/") - - -def classify_other_request(method: str, uri: str, status: int | None) -> str: - path = uri.split("?", 1)[0] or "/" - if len(path) > 80: - path = f"{path[:77]}..." - status_text = str(status) if status is not None else "unknown" - return f"{method.upper() or 'UNKNOWN'} {path} -> {status_text}" - - -def is_rate_limited(fields: dict[str, str], status: int | None) -> bool: - uri = fields.get("uri", "") - upstream_time = fields.get("upstream_time", "") - return status in {429, 503} and uri.split("?", 1)[0] in {"/mcp", "/mcp/"} and upstream_time in {"", "-"} - - -def parse_usage_line(line: str) -> dict[str, object] | None: - try: - payload = json.loads(line) - except json.JSONDecodeError: - return None - if not isinstance(payload, dict): - return None - ts = payload.get("ts") - tool = payload.get("tool") - if not isinstance(ts, str) or not isinstance(tool, str): - return None - return payload - - -def public_text(value: object, *, limit: int = 240) -> str | None: - if not isinstance(value, str): - return None - text = " ".join(value.split()) - if not text: - return None - return text[:limit] - - -def public_system(value: object) -> str: - system = public_text(value, limit=80) - if system is None: - return "unknown" - system = system.lower() - if system in SYSTEM_LABELS: - return system - return "unknown" - - -def public_url(value: object) -> str | None: - url = public_text(value, limit=500) - if url is None or not url.startswith("https://v8std.ru/"): - return None - return url - - -def public_result(value: object) -> dict[str, str] | None: - if not isinstance(value, dict): - return None - url = public_url(value.get("url")) - if url is None: - return None - result = {"url": url} - item_id = public_text(value.get("id"), limit=120) - title = public_text(value.get("title")) - if item_id: - result["id"] = item_id - if title: - result["title"] = title - return result - - -def public_ranked_item(value: object) -> dict[str, str] | None: - if not isinstance(value, dict): - return None - raw_url = public_text(value.get("url"), limit=500) - url = public_url(value.get("url")) - if raw_url and url is None: - return None - item_id = public_text(value.get("id"), limit=120) - code = public_text(value.get("code"), limit=120) - title = public_text(value.get("title")) - result: dict[str, str] = {} - if item_id: - result["id"] = item_id - elif code: - result["id"] = code - if title: - result["title"] = title - if url: - result["url"] = url - return result or None - - -def public_frequency(value: object) -> int: - if isinstance(value, int) and not isinstance(value, bool): - return max(1, min(value, TOP_RANKING_LIMIT * 10)) - return 1 - - -def add_diagnostic_ranking_item( - requests: Counter[str], - metadata: dict[str, dict[str, str]], - *, - item_id: str, - title: str, - frequency: int, - kind: str, - url: str = "", -) -> None: - key = f"{kind}:{url or item_id or title}" - requests[key] += frequency - metadata.setdefault( - key, - { - "key": key, - "id": item_id, - "title": title, - "url": url, - "kind": kind, - }, - ) - - -def human_duration(seconds: int | float | None) -> str: - if seconds is None: - return "unknown" - total_minutes = max(0, int(seconds) // 60) - days, day_minutes = divmod(total_minutes, 24 * 60) - hours, minutes = divmod(day_minutes, 60) - if days: - return f"{days}d {hours}h" - if hours: - return f"{hours}h {minutes}m" - return f"{minutes}m" - - -def normalize_uptime(uptime: dict[str, object] | None) -> dict[str, object]: - payload = { - "service": DEFAULT_SERVICE, - "active": None, - "active_since": None, - "seconds": None, - "human": "unknown", - "restarts": None, - } - if uptime: - payload.update(uptime) - if payload.get("active") is False: - payload["human"] = "down" - elif "human" not in payload or payload["human"] == "unknown": - payload["human"] = human_duration(payload.get("seconds")) # type: ignore[arg-type] - return payload - - -def counter_items(counter: Counter[str], labels: dict[str, str]) -> list[dict[str, object]]: - items = [ - { - "key": key, - "label": labels.get(key, key), - "count": count, - } - for key, count in counter.items() - if count > 0 - ] - return sorted(items, key=lambda item: (-int(item["count"]), str(item["label"]))) - - -def build_report( - log_lines: Iterable[str], - *, - usage_lines: Iterable[str] | None = None, - now: datetime | None = None, - window_hours: int = DEFAULT_WINDOW_HOURS, - uptime: dict[str, object] | None = None, -) -> dict[str, object]: - generated_at = now.astimezone(timezone.utc) if now else datetime.now(timezone.utc) - window_start = generated_at - timedelta(hours=window_hours) - future_cutoff = generated_at + timedelta(minutes=5) - - systems: Counter[str] = Counter() - tools: Counter[str] = Counter() - other_requests: Counter[str] = Counter() - page_requests: Counter[str] = Counter() - page_metadata: dict[str, dict[str, str]] = {} - search_events: list[tuple[datetime, dict[str, object]]] = [] - diagnostic_requests: Counter[str] = Counter() - diagnostic_metadata: dict[str, dict[str, str]] = {} - rate_limited = 0 - - for line in log_lines: - fields = parse_log_line(line) - if not fields: - continue - - ts = parse_iso_datetime(fields.get("ts", "")) - if ts is None or ts < window_start or ts > future_cutoff: - continue - - try: - status = int(fields.get("status", "")) - except ValueError: - status = None - - uri = fields.get("uri", "") - method = fields.get("method", "") - limited = is_rate_limited(fields, status) - if limited: - rate_limited += 1 - - if is_mcp_request(uri): - continue - - if not is_ignored_non_mcp_request(uri): - other_requests[classify_other_request(method, uri, status)] += 1 - - for line in usage_lines or []: - usage = parse_usage_line(line) - if not usage: - continue - ts = parse_iso_datetime(usage["ts"]) - if ts is None or ts < window_start or ts > future_cutoff: - continue - tool = usage["tool"] - tools[tool] += 1 - system = public_system(usage.get("system")) - if system not in UNIDENTIFIED_CLIENT_SYSTEMS: - systems[system] += 1 - if tool == "v8std_get_page": - url = public_url(usage.get("url")) - page_id = public_text(usage.get("page_id"), limit=120) - requested_page = public_text(usage.get("requested_page"), limit=120) - title = public_text(usage.get("title")) or page_id or requested_page or url - page_key = url or page_id or requested_page - if page_key: - page_requests[page_key] += 1 - page_metadata.setdefault( - page_key, - { - "key": page_key, - "title": title or page_key, - "url": url or "", - }, - ) - elif tool == "v8std_search": - query = public_text(usage.get("query")) - if query: - results = [] - seen = set() - raw_results = usage.get("results") - if isinstance(raw_results, list): - for raw_result in raw_results: - result = public_result(raw_result) - if result is None or result["url"] in seen: - continue - seen.add(result["url"]) - results.append(result) - if len(results) >= MAX_SEARCH_RESULTS_PER_QUERY: - break - search_events.append( - ( - ts, - { - "ts": ts.replace(microsecond=0).isoformat(), - "query": query, - "system": system, - "system_label": SYSTEM_LABELS[system], - "results": results, - }, - ) - ) - elif tool == "v8std_explain_diagnostics": - raw_diagnostics = usage.get("diagnostics") - if isinstance(raw_diagnostics, list): - for raw_diagnostic in raw_diagnostics: - diagnostic = public_ranked_item(raw_diagnostic) - if diagnostic is None: - continue - diagnostic_id = diagnostic.get("id") or diagnostic.get("url") or diagnostic.get("title") - if not diagnostic_id: - continue - frequency = public_frequency(raw_diagnostic.get("frequency") if isinstance(raw_diagnostic, dict) else None) - add_diagnostic_ranking_item( - diagnostic_requests, - diagnostic_metadata, - item_id=diagnostic_id, - title=diagnostic.get("title") or diagnostic_id, - url=diagnostic.get("url", ""), - frequency=frequency, - kind="diagnostic", - ) - raw_unknown_codes = usage.get("unknown_codes") - if isinstance(raw_unknown_codes, list): - for raw_unknown_code in raw_unknown_codes: - if not isinstance(raw_unknown_code, dict): - continue - code = public_text(raw_unknown_code.get("code"), limit=120) - if code is None: - continue - add_diagnostic_ranking_item( - diagnostic_requests, - diagnostic_metadata, - item_id=code, - title=f"Неизвестная диагностика: {code}", - frequency=public_frequency(raw_unknown_code.get("frequency")), - kind="unknown_code", - ) - raw_standards = usage.get("standards_without_page") - if isinstance(raw_standards, list): - for raw_standard in raw_standards: - standard = public_ranked_item(raw_standard) - if standard is None or standard.get("url"): - continue - standard_id = standard.get("id") or standard.get("title") - if not standard_id: - continue - standard_title = standard.get("title") or standard_id - add_diagnostic_ranking_item( - diagnostic_requests, - diagnostic_metadata, - item_id=standard_id, - title=f"Стандарт без страницы: {standard_title}", - frequency=public_frequency(raw_standard.get("frequency") if isinstance(raw_standard, dict) else None), - kind="standard_without_page", - ) - - tool_items = counter_items(tools, TOOL_LABELS) - tool_calls = sum(int(item["count"]) for item in tool_items) - top_pages = [ - {**page_metadata[key], "count": count} - for key, count in page_requests.most_common(TOP_RANKING_LIMIT) - ] - recent_searches = [ - event - for _ts, event in sorted(search_events, key=lambda item: item[0], reverse=True)[:RECENT_SEARCH_LIMIT] - ] - top_diagnostics = [ - {**diagnostic_metadata[key], "count": count} - for key, count in diagnostic_requests.most_common(TOP_RANKING_LIMIT) - ] - - return { - "generated_at": generated_at.replace(microsecond=0).isoformat(), - "window_hours": window_hours, - "window_start": window_start.replace(microsecond=0).isoformat(), - "totals": { - "mcp_requests": tool_calls, - "tool_calls": tool_calls, - "rate_limited": rate_limited, - }, - "tools": tool_items, - "top_pages": top_pages, - "recent_searches": recent_searches, - "top_diagnostics": top_diagnostics, - "systems": counter_items(systems, SYSTEM_LABELS), - "other_requests": counter_items(other_requests, {}), - "uptime": normalize_uptime(uptime), - } - - -def read_log_lines(paths: Iterable[Path]) -> Iterator[str]: - for path in paths: - if not path.exists(): - continue - opener = gzip.open if path.suffix == ".gz" else open - with opener(path, "rt", encoding="utf-8", errors="replace") as handle: - yield from handle - - -def expand_access_logs(path: Path) -> list[Path]: - candidates = [path, path.with_name(f"{path.name}.1"), path.with_name(f"{path.name}.1.gz")] - return [candidate for candidate in candidates if candidate.exists()] - - -def parse_systemd_timestamp(value: str) -> datetime | None: - if not value or value == "n/a": - return None - parts = value.split(maxsplit=1) - if len(parts) == 2 and "," not in parts[0]: - value = parts[1] - for fmt in ("%Y-%m-%d %H:%M:%S.%f %Z", "%Y-%m-%d %H:%M:%S %Z"): - try: - parsed = datetime.strptime(value, fmt) - except ValueError: - continue - return parsed.replace(tzinfo=timezone.utc) - return None - - -def read_service_uptime(service: str, *, now: datetime | None = None) -> dict[str, object]: - generated_at = now.astimezone(timezone.utc) if now else datetime.now(timezone.utc) - result = subprocess.run( - [ - "systemctl", - "show", - service, - "-p", - "ActiveState", - "-p", - "ActiveEnterTimestamp", - "-p", - "NRestarts", - "--no-pager", - ], - check=False, - capture_output=True, - text=True, - timeout=5, - ) - fields = {} - for line in result.stdout.splitlines(): - if "=" in line: - key, value = line.split("=", 1) - fields[key] = value - - active_since = parse_systemd_timestamp(fields.get("ActiveEnterTimestamp", "")) - active = fields.get("ActiveState") == "active" if fields else None - seconds = None - if active and active_since: - seconds = max(0, int((generated_at - active_since).total_seconds())) - try: - restarts: int | None = int(fields["NRestarts"]) - except (KeyError, ValueError): - restarts = None - - return { - "service": service, - "active": active, - "active_since": active_since.isoformat() if active_since else fields.get("ActiveEnterTimestamp") or None, - "seconds": seconds, - "restarts": restarts, - } - - -def metric_card(label: str, value: object, detail: str) -> str: - return ( - '
    ' - f'
    {html.escape(label)}
    ' - f'
    {html.escape(str(value))}
    ' - f'
    {html.escape(detail)}
    ' - "
    " - ) - - -def render_bar_list(items: list[dict[str, object]], empty_text: str) -> str: - if not items: - return f'

    {html.escape(empty_text)}

    ' - max_count = max(int(item["count"]) for item in items) or 1 - rows = [] - for item in items: - count = int(item["count"]) - width = max(4, round(count / max_count * 100)) - rows.append( - '
    ' - f'
    {html.escape(str(item["label"]))}{count}
    ' - '
    ' - f'
    ' - "
    " - "
    " - ) - return "\n".join(rows) - - -def render_link(url: object, label: object) -> str: - url_text = public_url(url) - label_text = public_text(label) or url_text or "" - if url_text is None: - return html.escape(label_text) - return f'{html.escape(label_text)}' - - -def render_page_ranking(items: list[dict[str, object]]) -> str: - if not items: - return '

    Данных по v8std_get_page пока нет.

    ' - rows = [] - limited_items = items[:TOP_RANKING_LIMIT] - for index, item in enumerate(limited_items, start=1): - title = item.get("title") or item.get("key") or item.get("url") - rows.append( - '
    ' - f'
    {index}
    ' - '
    ' - f'
    {render_link(item.get("url"), title)}
    ' - f'
    {html.escape(str(item.get("url") or item.get("key") or ""))}
    ' - "
    " - f'{int(item["count"])}' - "
    " - ) - split_index = (len(rows) + 1) // 2 - columns = [rows[:split_index], rows[split_index:]] - return "\n".join( - '
    ' + "\n".join(column_rows) + "
    " - for column_rows in columns - if column_rows - ) - - -def render_search_time(value: object) -> str: - if not isinstance(value, str): - return "" - ts = parse_iso_datetime(value) - if ts is None: - return "" - return ts.strftime("%H:%M UTC") - - -def render_search_ranking(items: list[dict[str, object]]) -> str: - if not items: - return '

    Данных по v8std_search пока нет.

    ' - rows = [] - for index, item in enumerate(items[:TOP_RANKING_LIMIT], start=1): - results = item.get("results") - result_rows = [] - if isinstance(results, list): - for result_index, result in enumerate(results[:MAX_SEARCH_RESULTS_PER_QUERY], start=1): - if isinstance(result, dict): - title = result.get("title") or result.get("id") or result.get("url") - meta = result.get("url") or result.get("id") or "" - result_rows.append( - '
    ' - f'
    {index}.{result_index}
    ' - '
    ' - f'
    {render_link(result.get("url"), title)}
    ' - f'
    {html.escape(str(meta))}
    ' - "
    " - "
    " - ) - rendered_results = ( - '
    ' + "\n".join(result_rows) + "
    " - if result_rows - else '
    Результатов в логе нет.
    ' - ) - meta_parts = [] - if item.get("system") not in UNIDENTIFIED_CLIENT_SYSTEMS: - meta_parts.append(str(item.get("system_label") or "")) - meta_parts.append("запрос v8std_search") - meta = " · ".join(part for part in meta_parts if part) - rows.append( - '
    ' - '
    ' - f'
    {index}
    ' - '
    ' - f'
    {html.escape(str(item["query"]))}
    ' - f'
    {html.escape(meta)}
    ' - "
    " - f'{html.escape(render_search_time(item.get("ts")))}' - "
    " - f"{rendered_results}" - "
    " - ) - return "\n".join(rows) - - -def render_diagnostic_ranking(items: list[dict[str, object]]) -> str: - if not items: - return '

    Данных по v8std_explain_diagnostics пока нет.

    ' - kind_labels = { - "unknown_code": "неизвестная диагностика", - "standard_without_page": "стандарт без страницы", - } - rows = [] - for index, item in enumerate(items[:TOP_RANKING_LIMIT], start=1): - title = item.get("title") or item.get("id") or item.get("key") or item.get("url") - diagnostic_id = public_text(item.get("id"), limit=120) - kind = public_text(item.get("kind"), limit=80) - meta_parts = [] - if kind in kind_labels: - meta_parts.append(kind_labels[kind]) - if diagnostic_id: - meta_parts.append(diagnostic_id) - meta = " · ".join(meta_parts) or item.get("url") or item.get("key") or "" - rows.append( - '
    ' - f'
    {index}
    ' - '
    ' - f'
    {render_link(item.get("url"), title)}
    ' - f'
    {html.escape(str(meta))}
    ' - "
    " - f'{int(item["count"])}' - "
    " - ) - return "\n".join(rows) - - -def render_html(report: dict[str, object]) -> str: - totals = report["totals"] # type: ignore[assignment] - uptime = report["uptime"] # type: ignore[assignment] - tools = report["tools"] # type: ignore[assignment] - top_pages = report["top_pages"] # type: ignore[assignment] - recent_searches = report["recent_searches"] # type: ignore[assignment] - top_diagnostics = report["top_diagnostics"] # type: ignore[assignment] - systems = report["systems"] # type: ignore[assignment] - other_requests = report["other_requests"] # type: ignore[assignment] - generated_at = str(report["generated_at"]) - window_hours = int(report["window_hours"]) - active = "active" if uptime.get("active") else "not active" if uptime.get("active") is False else "unknown" # type: ignore[attr-defined] - - metrics = "\n".join( - [ - metric_card("MCP запросы", totals["mcp_requests"], f"вызовы MCP tools за последние {window_hours} часа"), # type: ignore[index] - metric_card("Rate limit", totals["rate_limited"], "отклонено лимитом"), # type: ignore[index] - metric_card("Аптайм MCP", uptime.get("human", "unknown"), f'{uptime.get("service", DEFAULT_SERVICE)}: {active}'), # type: ignore[attr-defined] - ] - ) - - return f""" - - - - - - - Мониторинг v8std MCP - - - -
    -
    -
    -

    Мониторинг v8std MCP

    -
    -
    Обновлено
    {html.escape(generated_at)}
    -
    - -
    - {metrics} -
    - -
    -
    -

    MCP tools

    - {render_bar_list(tools, "Вызовов tools/call после включения счетчика пока нет.")} -
    - -
    -

    Системы

    - {render_bar_list(systems, "Клиенты MCP tools пока не накоплены.")} -
    -
    - -
    -
    -

    Последние 10 запросов search

    -
    - {render_search_ranking(recent_searches)} -
    -
    - -
    -

    Топ диагностик explain_diagnostics

    - {render_diagnostic_ranking(top_diagnostics)} -
    - -
    -

    Топ страниц get_page

    -
    - {render_page_ranking(top_pages)} -
    -
    -
    - -
    -

    Прочие запросы

    - {render_bar_list(other_requests[:10], "Прочих запросов нет.")} -
    - -
    - Данные агрегированы без IP-адресов. Системы считаются по MCP tool calls. - JSON: stats.json -
    -
    - - -""" - - -def write_dashboard(report: dict[str, object], output_dir: Path) -> None: - output_dir.mkdir(parents=True, exist_ok=True) - html_path = output_dir / "index.html" - json_path = output_dir / "stats.json" - html_tmp = output_dir / ".index.html.tmp" - json_tmp = output_dir / ".stats.json.tmp" - - html_tmp.write_text(render_html(report), encoding="utf-8") - json_tmp.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") - html_tmp.replace(html_path) - json_tmp.replace(json_path) - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Render a static public dashboard for the v8std MCP endpoint.") - parser.add_argument("--access-log", action="append", type=Path, default=None) - parser.add_argument("--usage-log", action="append", type=Path, default=None) - parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) - parser.add_argument("--service", default=DEFAULT_SERVICE) - parser.add_argument("--window-hours", type=int, default=DEFAULT_WINDOW_HOURS) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv) - access_logs = args.access_log or [DEFAULT_ACCESS_LOG] - log_paths: list[Path] = [] - for access_log in access_logs: - expanded = expand_access_logs(access_log) - log_paths.extend(expanded or [access_log]) - usage_logs = args.usage_log or [DEFAULT_USAGE_LOG] - usage_paths: list[Path] = [] - for usage_log in usage_logs: - expanded = expand_access_logs(usage_log) - usage_paths.extend(expanded or [usage_log]) - - now = datetime.now(timezone.utc) - report = build_report( - read_log_lines(log_paths), - usage_lines=read_log_lines(usage_paths), - now=now, - window_hours=args.window_hours, - uptime=read_service_uptime(args.service, now=now), - ) - write_dashboard(report, args.output_dir) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/spec/README.md b/spec/README.md index 402220c..5e9f7d9 100644 --- a/spec/README.md +++ b/spec/README.md @@ -1,86 +1,14 @@ -# Внутренние архитектурные документы +# Устройство и поставка v8std -`spec/` — непубликуемый корпус требований, решений, контрактов и планов v8std. -Он не входит в сайт, навигацию и AI-артефакты из `docs/`. +- [Согласованные архитектурные правила](architecture-review.md) — отдельные правила и проверяющие их тесты. +- [Проект инструментов для ИИ](mcp-tool-design.md) — обновлённые описания, дальнейшие изменения и границы совместимости с Unica. +- [Контракт поверхности MCP](mcp-surface-contract.md) — протокол, инструменты, параметры, ответы и ошибки. +- [Целевая поставка](delivery-target.md) — пять результатов и сценарии запуска. +- [Набор локальной сборки сайта](local-site-build-files.md) — проверенные входы. +- [Разделение каталогов](repository-layout-plan.md) — текущее размещение и оставшиеся работы. -Нормативный процесс: [`process:architecture-artifacts@1`](process/architecture-artifacts-v1.md). -Обязательный рабочий маршрут агента: [repo skill](../.agents/skills/v8std-architecture/SKILL.md). +- [Контейнерная установка](container-installation.md) — текущие команды и ограничения локальной поставки. -## Как работать +- [Переустановка VPS](vps-reinstallation.md) — подготовка, обследование и ограничения выпуска. -1. Прочитать `AGENTS.md`, эту страницу и нормативную process specification. -2. Создать отдельную ветку до первой записи. -3. Изучить фактические файлы и вручную оценить намерение и предполагаемые пути - по вопросам semantic impact check. Пустой Git diff на этом этапе ничего не - доказывает. -4. Если влияние на требования, ADR, инварианты или контракты исключено, - использовать тривиальный путь с теми же Git- и merge-gates. -5. Если влияние найдено или не исключено, остановить изменения, выполнить - brainstorming и выбрать необходимые документы. -6. После письменного согласования design создать plan до начала реализации. -7. Если реализация опровергла design или impact check, вернуться к комплексному - проектированию, а не ослаблять gate. -8. После появления diff и перед локальным merge запустить CLI `impact`, проверить - все изменённые пути, повторить semantic impact check и выполнить все gates. - -## Какой документ создавать - -| Причина | Документ | -|---|---| -| Новое или изменённое обязательство | `design` с кодом требования | -| Выбор между архитектурными альтернативами | Один атомарный `ADR` | -| Проверяемое долговечное свойство продукта | `invariant` и fitness declaration | -| Наблюдаемая граница producer/consumer | Версионированный `contract` | -| Связанный пакет требований и решений | `design`, соединяющий граф | -| Согласованное нетривиальное изменение нужно реализовать | `plan` с явным `implements` | -| Меняется сам процесс репозитория | `process`, skill или `AGENTS.md`, но не product invariant | - -Принятый design может не иметь plan и остаётся только `ACCEPTED`. -`IMPLEMENTED` появляется лишь после завершённого принятого plan, который явно -указывает артефакт в `implements`. -Plan заканчивается до integration gate: commit, merge, явно разрешённый push с -автоматической публикацией сайта и отдельный ручной MCP deployment не являются -его checkbox-задачами. - -После локального merge push проверенного `main` выполняется только по явному -запросу и автоматически публикует сайт. Merge, push и site deployment не дают -разрешения на MCP deployment: для него нужен отдельный явный запрос и точный SHA -из `main`. - -## Каталоги и примеры структуры - -- [`spec/designs/`](designs/) — требования и связанные решения; [пример](designs/2026-08-14-mcp-v3-resource-contract-design.md); -- [`spec/adr/`](adr/) — атомарные решения; [пример](adr/2026-08-14-page-reading-via-resources.md); -- [`spec/invariants/`](invariants/) — свойства продукта; [пример](invariants/mcp-resource-version-page-reading-via-resources.md); -- [`spec/contracts/`](contracts/) — наблюдаемые границы; [пример](contracts/mcp-api-v3-r0.md); -- [`spec/plans/`](plans/) — планы реализации; [пример](plans/2026-08-14-architecture-process-usability-fix-plan.md); -- [`spec/process/`](process/) — версии архитектурного процесса. - -Front matter хранит нормативные коды, ссылки и отношения. Markdown объясняет -контекст, причины и последствия и не должен создавать второй нормативный список. -Принятый structured-документ не редактируется: создаётся преемник, новая версия -или ревизия. - -## Команды - -```bash -# Кандидаты архитектурного влияния текущего diff -.venv/bin/python scripts/v8std_architecture.py impact --root . --base-ref main - -# Пустой вывод impact не доказывает тривиальность: проверить все пути вручную - -# Вычисленные состояния документов -.venv/bin/python scripts/v8std_architecture.py status --root . --main-ref main - -# Обычная проверка во время работы -.venv/bin/python scripts/v8std_architecture.py validate --root . - -# Обязательная проверка перед локальным merge -.venv/bin/python scripts/v8std_architecture.py validate --root . --base-ref main --merge-ready - -# Полный test suite -.venv/bin/python -m unittest discover -s tests -v - -# Strict build -VIRTUAL_ENV="$PWD/.venv" ./scripts/zensical_docs.sh build --strict -``` +Правила работы: [AGENTS.md](../AGENTS.md). `docs/` содержит контент сайта. diff --git a/spec/adr/2026-08-14-local-openmetrics-exposition.md b/spec/adr/2026-08-14-local-openmetrics-exposition.md deleted file mode 100644 index d790ef5..0000000 --- a/spec/adr/2026-08-14-local-openmetrics-exposition.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -schema_version: 1 -kind: adr -id: LOCAL_OPENMETRICS_EXPOSITION -scope: product -design: design:mcp-openmetrics-generation -requirements: - - OPENMETRICS_IS_GENERATED_LOCALLY - - PROMETHEUS_INTEGRATION_IS_DEFERRED - - METRICS_ENDPOINTS_USE_LOOPBACK -aliases: [ADR-0003] -supersedes: [] -cancels: [] -invariants: - introduces: - - invariant:METRICS_ENDPOINTS_ARE_LOOPBACK_ONLY - - invariant:METRICS_LABEL_CARDINALITY_IS_BOUNDED - preserves: [] - replaces: {} - cancels: [] -contracts: - introduces: [contract:MCP_OPENMETRICS@1.0] - preserves: [] - replaces: {} - cancels: [] ---- - -# Локальная генерация OpenMetrics - -## Входные требования - -- `OPENMETRICS_IS_GENERATED_LOCALLY`; -- `PROMETHEUS_INTEGRATION_IS_DEFERRED`; -- `METRICS_ENDPOINTS_USE_LOOPBACK`. - -## Решение - -Генерировать OpenMetrics внутри MCP v2/v3 и отдавать exposition только через -loopback. Подключение к единому Prometheus вынести в отдельное последующее -решение. - -## Влияние на инварианты - -Вводятся `METRICS_ENDPOINTS_ARE_LOOPBACK_ONLY` и -`METRICS_LABEL_CARDINALITY_IS_BOUNDED`. - -## Влияние на контракты - -Вводится `contract:MCP_OPENMETRICS@1.0`; имена метрик, labels и семантика -значений становятся наблюдаемым versioned boundary. - -## Отклонённые альтернативы - -Немедленная production-интеграция с Prometheus отклонена как отдельный объём. -Публичный `/metrics` отклонён из-за ненужной внешней поверхности доступа. diff --git a/spec/adr/2026-08-14-mcp-version-endpoint-isolation.md b/spec/adr/2026-08-14-mcp-version-endpoint-isolation.md deleted file mode 100644 index c2dcfa9..0000000 --- a/spec/adr/2026-08-14-mcp-version-endpoint-isolation.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -schema_version: 1 -kind: adr -id: MCP_VERSION_ENDPOINT_ISOLATION -scope: product -design: design:mcp-v3-resource-contract -requirements: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_VERSIONS_FAIL_INDEPENDENTLY -aliases: [ADR-0001] -supersedes: [] -cancels: [] -invariants: - introduces: - - invariant:MCP_VERSION_ISOLATION - - invariant:MCP_LEGACY_ENDPOINT_STABILITY - preserves: [] - replaces: {} - cancels: [] -contracts: - introduces: [contract:MCP_API@3.0] - preserves: [contract:MCP_API@2.0] - replaces: {} - cancels: [] ---- - -# Изоляция endpoint версий MCP - -## Входные требования - -- `MCP_LEGACY_VERSION_REMAINS_COMPATIBLE`; -- `MCP_VERSIONS_FAIL_INDEPENDENTLY`. - -## Решение - -Разместить MCP v3 на отдельном endpoint `/v3/mcp`. Существующий `/mcp` -продолжает обслуживать MCP v2 без согласования версии внутри одного endpoint. - -## Влияние на инварианты - -Решение вводит `MCP_VERSION_ISOLATION` и -`MCP_LEGACY_ENDPOINT_STABILITY`. Смешанная маршрутизация версий опровергает -решение. - -## Влияние на контракты - -`contract:MCP_API@2.0` сохраняется, а breaking-контракт -`contract:MCP_API@3.0` вводится независимо. - -## Отклонённые альтернативы - -Изменение `/mcp` на месте и version negotiation отклонены: они связывают -release, отказ и rollback новой версии с действующими клиентами. diff --git a/spec/adr/2026-08-14-page-reading-via-resources.md b/spec/adr/2026-08-14-page-reading-via-resources.md deleted file mode 100644 index e992317..0000000 --- a/spec/adr/2026-08-14-page-reading-via-resources.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -schema_version: 1 -kind: adr -id: PAGE_READING_VIA_RESOURCES -scope: product -design: design:mcp-v3-resource-contract -requirements: - - MCP_RESOURCE_VERSION_PAGE_READING_USES_RESOURCES - - MCP_RESOURCE_VERSION_HAS_ONE_PRIMARY_PAGE_READER -aliases: [ADR-0004] -supersedes: [] -cancels: [] -invariants: - introduces: - - invariant:MCP_RESOURCE_VERSION_PAGE_READING_VIA_RESOURCES - - invariant:MCP_RESOURCE_LINKS_ARE_LISTABLE - preserves: [] - replaces: {} - cancels: [] -contracts: - introduces: [] - preserves: [contract:MCP_API@3.0] - replaces: {} - cancels: [] ---- - -# Чтение страниц MCP v3 через Resources - -## Входные требования - -- `MCP_RESOURCE_VERSION_PAGE_READING_USES_RESOURCES`; -- `MCP_RESOURCE_VERSION_HAS_ONE_PRIMARY_PAGE_READER`. - -## Решение - -В MCP v3 читать страницы через `resources/list` и `resources/read`. -`v8std_get_page` не включать в v3, чтобы не поддерживать два равноправных -механизма чтения одного содержания. - -## Влияние на инварианты - -Вводятся `MCP_RESOURCE_VERSION_PAGE_READING_VIA_RESOURCES` и -`MCP_RESOURCE_LINKS_ARE_LISTABLE`. - -## Влияние на контракты - -Решение конкретизирует и сохраняет `contract:MCP_API@3.0`: page content, -pagination, cursor и resource links описываются в этом контракте. - -## Отклонённые альтернативы - -Сохранение `v8std_get_page` рядом с Resources отклонено из-за дублирования -семантики. Перевод MCP v2 на Resources не входит в решение. diff --git a/spec/adr/2026-08-14-public-mcp-monitoring.md b/spec/adr/2026-08-14-public-mcp-monitoring.md deleted file mode 100644 index dce8cad..0000000 --- a/spec/adr/2026-08-14-public-mcp-monitoring.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -schema_version: 1 -kind: adr -id: PUBLIC_MCP_MONITORING -scope: product -design: design:mcp-monitoring-dashboard -requirements: - - MONITORING_SHOWS_AGENT_FAMILIES - - MONITORING_SHOWS_API_VERSIONS - - MONITORING_SHOWS_MCP_OPERATIONS - - PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA - - OPERATOR_DETAILS_STAY_OUTSIDE_WEB_ROOT - - MONITORING_REMAINS_PUBLIC -aliases: [ADR-0002] -supersedes: [] -cancels: [] -invariants: - introduces: - - invariant:PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA - - invariant:OPERATOR_DATA_STAYS_OUTSIDE_WEB_ROOT - preserves: [] - replaces: {} - cancels: [] -contracts: - introduces: - - contract:MCP_USAGE_EVENTS@2.0 - - contract:MCP_MONITORING_PROJECTION@2.0 - preserves: - - contract:MCP_USAGE_EVENTS@1.0 - - contract:MCP_MONITORING_PROJECTION@1.0 - replaces: {} - cancels: [] ---- - -# Публичный агрегированный мониторинг MCP - -## Входные требования - -- `MONITORING_SHOWS_AGENT_FAMILIES`; -- `MONITORING_SHOWS_API_VERSIONS`; -- `MONITORING_SHOWS_MCP_OPERATIONS`; -- `PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA`; -- `OPERATOR_DETAILS_STAY_OUTSIDE_WEB_ROOT`; -- `MONITORING_REMAINS_PUBLIC`. - -## Решение - -Оставить `/monitoring/` публичной безопасной агрегированной проекцией. Детальные -операторские данные хранить вне web root и получать через SSH, без добавления -аутентификации к публичной странице. - -## Влияние на инварианты - -Вводятся `PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA` и -`OPERATOR_DATA_STAYS_OUTSIDE_WEB_ROOT`. Публикация сырого события нарушает -решение независимо от удобства dashboard. - -## Влияние на контракты - -Legacy contracts `MCP_USAGE_EVENTS@1.0` и `MCP_MONITORING_PROJECTION@1.0` -сохраняются для чтения истории. Новые события и проекции оформляются версиями -`2.0`. - -## Отклонённые альтернативы - -Полное закрытие страницы аутентификацией отклонено как ненужное. Публикация -детального отчёта отклонена из-за утечки чувствительных и высококардинальных -значений. diff --git a/spec/adr/2026-09-03-mcp-combined-endpoint.md b/spec/adr/2026-09-03-mcp-combined-endpoint.md deleted file mode 100644 index 35cdebc..0000000 --- a/spec/adr/2026-09-03-mcp-combined-endpoint.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -schema_version: 1 -kind: adr -id: MCP_COMBINED_ENDPOINT -scope: product -design: design:mcp-combined-endpoint -requirements: - - MCP_COMBINED_ENDPOINT_CAPABILITIES - - MCP_COMBINED_PAGE_READING_COMPATIBLE -aliases: [] -supersedes: - - adr:MCP_VERSION_ENDPOINT_ISOLATION - - adr:PAGE_READING_VIA_RESOURCES -cancels: [] -invariants: - introduces: - - invariant:MCP_COMBINED_ENDPOINT_IS_SINGLE_RUNTIME - preserves: - - invariant:MCP_LEGACY_ENDPOINT_STABILITY - - invariant:MCP_RESOURCE_LINKS_ARE_LISTABLE - replaces: - invariant:MCP_VERSION_ISOLATION: invariant:MCP_COMBINED_ENDPOINT_IS_SINGLE_RUNTIME - invariant:MCP_RESOURCE_VERSION_PAGE_READING_VIA_RESOURCES: invariant:MCP_COMBINED_ENDPOINT_IS_SINGLE_RUNTIME - cancels: [] -contracts: - introduces: - - contract:MCP_API@2.2 - preserves: - - contract:MCP_API@2.0 - - contract:MCP_API@2.1 - replaces: - contract:MCP_API@3.0: contract:MCP_API@2.2 - cancels: [] ---- - -# Один комбинированный MCP runtime - -## Входные требования - -- `MCP_COMBINED_ENDPOINT_CAPABILITIES`; -- `MCP_COMBINED_PAGE_READING_COMPATIBLE`. - -## Решение - -Оставить единственный `/mcp` и единственный `v8std-mcp.service`. Текущие v2 -tools и будущий additive Resources-профиль регистрируются в одном -`v8std_mcp_server.py`; отдельные `/v3/mcp`, порт и процесс не создаются. - -`v8std_get_page` не удаляется. Клиенты с поддержкой Resources получают новый -способ чтения страниц, а старые tool-only клиенты продолжают работать. - -## Влияние на инварианты - -Решение заменяет изоляцию версий инвариантом единственного runtime и сохраняет -стабильность legacy endpoint. Ошибка или незавершённость будущего Resources -слоя не должна требовать второго публичного сервиса. - -## Влияние на контракты - -Вводится backward-compatible `MCP_API@2.2`, который сохраняет v2 surface и -добавляет capability profile. Старый design-only `MCP_API@3.0` больше не -рассматривается как отдельный публичный endpoint-контракт. - -## Отклонённые альтернативы - -- `/v3/mcp` и отдельный процесс; -- breaking removal `v8std_get_page`; -- эвристическое negotiation по имени клиента. diff --git a/spec/adr/2026-09-03-mcp-post-only-edge-drain.md b/spec/adr/2026-09-03-mcp-post-only-edge-drain.md deleted file mode 100644 index b28b502..0000000 --- a/spec/adr/2026-09-03-mcp-post-only-edge-drain.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -schema_version: 1 -kind: adr -id: MCP_POST_ONLY_EDGE_DRAIN -scope: product -design: design:mcp-100k-agent-capacity -requirements: - - MCP_POST_ONLY_AGENT_TRANSPORT - - MCP_EDGE_CONNECTION_CAPACITY - - MCP_WORKER_DRAIN_IS_BOUNDED -aliases: [] -supersedes: [] -cancels: [] -invariants: - introduces: - - invariant:MCP_POST_ONLY_TRANSPORT - - invariant:MCP_EDGE_DRAIN_IS_BOUNDED - - invariant:MCP_OVERLOAD_IS_RETRYABLE - preserves: - - invariant:MCP_LEGACY_ENDPOINT_STABILITY - replaces: {} - cancels: [] -contracts: - introduces: - - contract:MCP_API@2.1 - preserves: - - contract:MCP_API@2.0 - replaces: {} - cancels: [] ---- - -# POST-only MCP edge and bounded drain - -## Входные требования - -- `MCP_POST_ONLY_AGENT_TRANSPORT`; -- `MCP_EDGE_CONNECTION_CAPACITY`; -- `MCP_WORKER_DRAIN_IS_BOUNDED`. - -## Решение - -The read-only stateless MCP service does not need unsolicited server-to-client -messages. The canonical client operation is therefore request-scoped POST. -The optional GET SSE stream is rejected with 405, while tools, resources, -initialize, browser documentation, and health/version routes remain available. - -Connection capacity belongs to a horizontally scaled edge fleet. Backend -replicas are stateless and are reached through bounded upstream pools. Nginx -workers have an explicit shutdown deadline so a reload cannot leave old SSE -workers consuming capacity for days. - -## Влияние на инварианты - -The decision introduces bounded POST-only transport and edge drain invariants, -and preserves the existing v2 endpoint tool and POST behavior. - -## Влияние на контракты - -This preserves the v2 tool and POST contract; the transport clarification is a -backward-compatible revision because Streamable HTTP permits a server without -an unsolicited GET SSE stream. - -## Отклонённые альтернативы - -- raising `worker_connections` without removing unbounded SSE streams; -- using a hard per-IP quota as the primary fairness mechanism; -- keeping one upstream socket per idle coding agent. diff --git a/spec/adr/2026-09-09-normalize-fences-at-render-boundary.md b/spec/adr/2026-09-09-normalize-fences-at-render-boundary.md deleted file mode 100644 index 995eeb7..0000000 --- a/spec/adr/2026-09-09-normalize-fences-at-render-boundary.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -schema_version: 1 -kind: adr -id: NORMALIZE_FENCES_AT_RENDER_BOUNDARY -scope: product -design: design:markdown-fence-rendering -requirements: - - MARKDOWN_FENCES_ARE_RENDERED_AS_BLOCKS - - MARKDOWN_RENDERING_PRESERVES_SOURCE_CORPUS - - MARKDOWN_RENDERING_PRESERVES_CODE_AND_CONTEXT - - ARTICLE_HTML_REJECTS_LAYOUT_BREAKING_STRUCTURE - - MARKDOWN_BUILD_AND_SERVE_SHARE_RENDERING -aliases: [] -supersedes: [] -cancels: [] -invariants: - introduces: - - invariant:MARKDOWN_RENDERING_PRESERVES_SOURCE_AND_CODE - preserves: [] - replaces: {} - cancels: [] -contracts: - introduces: - - contract:ARTICLE_HTML@1.0 - preserves: - - contract:DIAGNOSTIC_CHIP_MARKUP@1.0 - replaces: {} - cancels: [] ---- - -# Нормализовать границы fenced-блоков при рендеринге - -## Входные требования - -Требования рендеринга блоков, сохранности исходников и кода, HTML-проверки -и единого поведения `build`/`serve` введены в -`design:markdown-fence-rendering` и перечислены в front matter. - -## Решение - -Использовать общее Markdown-расширение в pipeline Zensical, которое отделяет -распознанные fenced-блоки от соседнего текста в рабочем представлении -документа. Сохранить исходный корпус, код, CSS и публичные идентификаторы. -Проверять структурную пригодность полученного HTML до публикации. - -Issue #35 возникает при сочетании отсутствующих разделителей SuperFences и -grid-раскладки статьи. Нормализация на границе рендеринга устраняет создание -блочного HTML внутри абзаца, работает до исполнения JavaScript и применяется -как при `build`, так и при `serve`. - -Заимствованные статьи имеют проверяемые хеши. Их ручное редактирование ради -синтаксических требований конкретного renderer смешало бы исходный корпус с -его HTML-представлением и потребовало бы сопровождения при синхронизациях. - -## Влияние на инварианты - -Вводится `MARKDOWN_RENDERING_PRESERVES_SOURCE_AND_CODE`: рендеринг не меняет -файлы исходного корпуса, provenance и содержимое кода. Существующие -инварианты не отменяются и не заменяются. - -## Влияние на контракты - -Вводится `ARTICLE_HTML@1.0` для границы renderer/CSS/browser. -`DIAGNOSTIC_CHIP_MARKUP@1.0` сохраняется без изменения ссылок, обязательных -классов и доступности. MCP-контракты не затронуты. - -Появляется небольшой адаптер между исходным Markdown и существующим renderer. -Он не является универсальным исправителем Markdown/HTML. Если вход нельзя -корректно обработать без изменения смысла, нельзя скрывать нарушение, -удалять текст или ослаблять HTML gate: требуется воспроизведение и пересмотр -решения. Принятие ADR не означает, что расширение и проверки уже реализованы. - -## Отклонённые альтернативы - -- Добавлять пустые строки непосредственно в статьи: затрагивает проверяемые - source blocks и возвращает задачу при следующем импорте. -- Ограничить ширину колонки или отказаться от CSS Grid: оставляет некорректный - HTML и меняет визуальный контракт всех стандартов. -- Переписывать DOM в браузере либо HTML после сборки: создаёт отдельный путь - исправления и риск различий между первой загрузкой, `build` и `serve`. -- Обновить renderer и сопутствующие библиотеки: расширяет область регрессий; - подтверждённого исправления в иной версии нет в основании этого решения. diff --git a/spec/adr/2026-09-10-snippet-signals-outside-text-query.md b/spec/adr/2026-09-10-snippet-signals-outside-text-query.md deleted file mode 100644 index 0f169c6..0000000 --- a/spec/adr/2026-09-10-snippet-signals-outside-text-query.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -schema_version: 1 -kind: adr -id: SNIPPET_SIGNALS_OUTSIDE_TEXT_QUERY -scope: product -design: design:mcp-large-procedure-retrieval -requirements: - - MCP_SNIPPET_ACCEPTED_INPUT_IS_SCANNED - - MCP_SNIPPET_TARGETS_SURVIVE_QUERY_BUDGET - - MCP_SNIPPET_INSTANCE_LIMIT_IS_DISCOVERABLE - - MCP_SNIPPET_RETRIEVAL_WORK_IS_BOUNDED - - MCP_SNIPPET_RESPONSE_STAYS_COMPACT -aliases: [] -supersedes: [] -cancels: [] -invariants: - introduces: - - invariant:MCP_SNIPPET_SIGNALS_SURVIVE_TEXT_BUDGET - preserves: - - invariant:MCP_LEGACY_ENDPOINT_STABILITY - - invariant:MCP_COMBINED_ENDPOINT_IS_SINGLE_RUNTIME - replaces: {} - cancels: [] -contracts: - introduces: - - contract:MCP_API@2.3 - preserves: [] - replaces: - contract:MCP_API@2.2: contract:MCP_API@2.3 - cancels: [] ---- - -# Структурные признаки snippet вне текстового поискового запроса - -## Входные требования - -Принятый фрагмент должен сканироваться целиком, найденные цели — сохраняться -при ограниченном текстовом запросе. Согласованный design также ограничивает -число поисковых проходов и размер ответа, а фактический лимит экземпляра -делает доступным агенту до вызова. - -## Решение - -Не использовать строку обычного поиска как единственный канал передачи -результатов анализа кода. По полному принятому snippet извлекаются признаки; -их цели разрешаются непосредственно в индексе и имеют отдельный приоритет -перед общим top-K. Одновременно выполняется не более одного гибридного поиска -по контексту не длиннее 500 символов. Слияние принадлежит `explain_snippet`, -а не меняет публичный ранжировщик `search`. - -Такой выбор позволяет расширять локальное окно кода в явно ограниченном -диапазоне, не расширяя текстовый запрос и число поисковых проходов. Настройка -экземпляра, согласованная tool schema и компактный ответ — условия применения -этого решения, подробно определённые в связанном design и контракте. - -## Влияние на инварианты - -Вводится проверка сохранения первичных целей при достаточном K независимо -от позиции в процедуре и текстового бюджета. Она также проверяет отсутствие -поискового fan-out. Стабильность legacy endpoint и единый runtime сохраняются; -инварианты предшествующих решений не отменяются. - -## Влияние на контракты - -Совместимая ревизия `MCP_API@2.3` уточняет `MCP_API@2.2`: schema сообщает -реальный input limit, локальное расширение явное, snippet-response остаётся -компактным. Меняется ранжирование рекомендаций snippet, но не обычного поиска. -Top-K не гарантирует выдачу всех целей, если их больше K, и не означает -доказанного нарушения: распознаватели остаются эвристическими. Legacy tools, -формат ответов, Resources-профиль и POST-only transport сохраняются. - -## Отклонённые альтернативы - -Обрезать единую строку проще, но это теряет распознанные цели. Располагать их -в начале строки недостаточно: число целей и их суммарная длина могут превысить -бюджет, а текстовое ранжирование всё равно не гарантирует место прямой цели. -Поиск по блокам/по каждому сигналу увеличивает число проходов; увеличение -общего лимита query меняет чужой публичный контракт. diff --git a/spec/architecture-review.md b/spec/architecture-review.md new file mode 100644 index 0000000..ddb44bc --- /dev/null +++ b/spec/architecture-review.md @@ -0,0 +1,59 @@ +# Согласованные архитектурные правила + +Все 33 правила согласованы пользователем 17 сентября 2026 года. +Каждое правило находится в отдельном файле с конкретным тестом и границей доказательства. +Согласование правила не означает успешного выполнения всех проверок или готовности production. + +Имена файлов начинаются с даты создания в формате YYYY-MM-DD. При обычной правке +дата сохраняется; внутри одной даты файлы сортируются по имени. + +| Дата создания | Одно правило | Вид доказательства | +|---|---|---| +| 2026-09-17 | [Разные источники имеют отдельный кэш](rules/2026-09-17-cache-source-isolation.md) | Поведенческий тест | +| 2026-09-17 | [Перенос ссылок не изменяет примеры кода](rules/2026-09-17-code-literals-unchanged.md) | Поведенческий тест | +| 2026-09-17 | [Отсутствие индекса означает неготовность](rules/2026-09-17-cold-index-not-ready.md) | Поведенческий тест | +| 2026-09-17 | [Дисковое поколение переключается атомарно](rules/2026-09-17-disk-generation-atomic.md) | Поведенческий тест | +| 2026-09-17 | [Неудачное обновление сохраняет рабочий индекс](rules/2026-09-17-failed-refresh-keeps-index.md) | Поведенческий тест | +| 2026-09-17 | [Тег исходной версии не перезаписывается](rules/2026-09-17-immutable-image-tag.md) | Поведенческий тест | +| 2026-09-17 | [Индекс доступен при недоступном MCP](rules/2026-09-17-index-independent-of-runtime.md) | Отдельно включаемая Docker-проверка | +| 2026-09-17 | [Первичная установка принимается после проверки](rules/2026-09-17-initial-acceptance-after-smoke.md) | Поведенческий тест | +| 2026-09-17 | [Первичная установка недоступна CI](rules/2026-09-17-initial-install-operator-only.md) | Поведенческий тест | +| 2026-09-17 | [Загрузка индекса ограничена временем](rules/2026-09-17-loader-has-deadline.md) | Поведенческий тест | +| 2026-09-17 | [Локальная упаковка не меняет исходники](rules/2026-09-17-local-build-preserves-source.md) | Отдельно включаемая интеграционная проверка | +| 2026-09-17 | [Локальный HTML не подключает публичную аналитику](rules/2026-09-17-local-html-no-analytics.md) | Отдельно включаемая интеграционная проверка | +| 2026-09-17 | [Локальный HTML не подключает публичные шрифты](rules/2026-09-17-local-html-no-public-fonts.md) | Отдельно включаемая интеграционная проверка | +| 2026-09-17 | [Ссылки статей указывают на выбранный сайт](rules/2026-09-17-local-page-links.md) | Поведенческий тест | +| 2026-09-17 | [Локальный источник не заменяется публичным](rules/2026-09-17-local-source-no-fallback.md) | Поведенческий тест | +| 2026-09-17 | [Манифест появляется после архива](rules/2026-09-17-manifest-after-archive.md) | Поведенческий тест | +| 2026-09-17 | [Нормализация Markdown сохраняет код](rules/2026-09-17-markdown-code-preserved.md) | Поведенческий тест | +| 2026-09-17 | [Рендерер не переписывает исходную статью](rules/2026-09-17-markdown-source-preserved.md) | Поведенческий тест | +| 2026-09-17 | [MCP использует только HTTP](rules/2026-09-17-mcp-http-only.md) | CLI и команда образа | +| 2026-09-17 | [MCP не открывает поток по GET](rules/2026-09-17-mcp-no-idle-stream.md) | Поведенческий тест | +| 2026-09-17 | [MCP не предоставляет Resources](rules/2026-09-17-mcp-no-resources.md) | Поведенческий тест | +| 2026-09-17 | [Каталог инструментов HTTP MCP](rules/2026-09-17-mcp-tool-catalog.md) | Поведенческий тест | +| 2026-09-17 | [Публичный мониторинг закрыт](rules/2026-09-17-monitoring-gone.md) | Отдельно включаемая Docker-проверка | +| 2026-09-17 | [Офлайн-запуск использует проверенный кэш](rules/2026-09-17-offline-verified-cache.md) | Поведенческий тест | +| 2026-09-17 | [Runtime получает только приватный файл журнала](rules/2026-09-17-private-log-mount.md) | Поведенческий тест | +| 2026-09-17 | [Запрос использует одно поколение индекса](rules/2026-09-17-request-one-generation.md) | Поведенческий тест | +| 2026-09-17 | [Обновление требует предшественника](rules/2026-09-17-rollout-needs-predecessor.md) | Поведенческий тест | +| 2026-09-17 | [Неудачное переключение возвращает предшественника](rules/2026-09-17-rollout-restores-predecessor.md) | Поведенческий тест | +| 2026-09-17 | [Активация runtime задаётся отдельно](rules/2026-09-17-runtime-activation-separate.md) | Поведенческий тест | +| 2026-09-17 | [Runtime и корпус версионируются независимо](rules/2026-09-17-runtime-corpus-separate.md) | Поведенческий тест | +| 2026-09-17 | [Неизменившийся индекс не пересобирается](rules/2026-09-17-same-index-no-rebuild.md) | Поведенческий тест | +| 2026-09-17 | [Сайт публикуется без активации MCP](rules/2026-09-17-site-publication-independent.md) | Статическая проверка workflow | +| 2026-09-17 | [Одинаковые входы дают одинаковый архив](rules/2026-09-17-snapshot-reproducible.md) | Поведенческий тест | + + +## Удаление прежних материалов + +По указанию пользователя удалены spec/archive и dev/archive целиком: +прежние архитектурные документы, процесс, инструменты, конфигурации и архивные тесты. +Согласованные правила самодостаточны и больше не ссылаются на эти документы. + +Пять ранее отключённых проверок за пределами удалённых каталогов остаются отключёнными: +три незавершённых handoff-теста, подсчёт FastMCP вместо проверки топологии +и фиксация версий пакетов вместо проверки независимости модулей. +Это не покрытие согласованных правил и не критерий готовности выпуска. + +Цель и незавершённые сценарии поставки: [delivery-target.md](delivery-target.md) +и [разделение каталогов](repository-layout-plan.md). diff --git a/spec/container-installation.md b/spec/container-installation.md new file mode 100644 index 0000000..39d236e --- /dev/null +++ b/spec/container-installation.md @@ -0,0 +1,111 @@ +# Локальная установка в контейнерах + +Поставка состоит из двух независимых образов: `ghcr.io/zeegin/v8std-mcp` +с MCP runtime и `ghcr.io/zeegin/v8std-site` с готовым локальным сайтом. +В CI настроена сборка для `linux/arm64` и `linux/amd64`; это не подтверждение +проверки текущего выпуска на обеих платформах. + +**Публикация текущего кандидата ещё не выполнена.** Команды ниже требуют действительных +image references из проверенного выпуска. Наличие Dockerfile не означает публикацию в GHCR. Не используйте +`release-pending` как установленную версию. Для локальной проверки разработчик +может явно передать теги своих тестовых образов вместо release references. + +## Сайт и MCP HTTP + +Получите `delivery/local/compose.yaml` из той же версии исходников и укажите проверенные +references вида `ghcr.io/zeegin/v8std-site@sha256:…` и +`ghcr.io/zeegin/v8std-mcp@sha256:…`. Значение digest относится к опубликованному +multi-platform index; локальный Docker image ID не заменяет этот digest. + +У MCP и сайта разные digests. Для неизменяемой версии используйте подтверждённый +`sha-` или digest; `stable` служит для обнаружения версии, а не для +фиксации установки. Обновление статей может сохранить прежний runtime SHA: +это повторное использование того же образа, не новая метка исходников. +Публикация образов и корпуса не включает автоматически развёртывание сервера. + +```bash +export V8STD_SITE_IMAGE='ghcr.io/zeegin/v8std-site@sha256:RELEASE_INDEX_DIGEST' +export V8STD_MCP_IMAGE='ghcr.io/zeegin/v8std-mcp@sha256:RELEASE_INDEX_DIGEST' +docker compose -p v8std-local -f delivery/local/compose.yaml up -d site +docker compose -p v8std-local -f delivery/local/compose.yaml --profile mcp up -d +curl --fail http://v8std.localhost:18765/ai/mcp/v1/manifest.json +curl --fail http://127.0.0.1:18766/healthz +``` + +Сайт открывается по адресу `http://v8std.localhost:18765/`, MCP — по адресу +`http://127.0.0.1:18766/mcp`. До подготовки проверенного поколения `/healthz` +отвечает `503`; `/livez` показывает только жизнь процесса. В `tools/list` +доступны пять инструментов: `v8std_search`, `v8std_get_page`, `v8std_get_related`, +`v8std_explain_snippet`, `v8std_explain_diagnostics`. MCP Resources отключены +в HTTP: capability `resources` отсутствует, все Resource methods +возвращают `-32601 Method not found`. Клиентам, использовавшим `resources/read`, +нужно перейти на поиск и чтение отдельных страниц через инструменты. +Файлы сайта `llms.txt`, `llms-full.txt` и `ai/pages.jsonl` остаются доступны +для скачивания; состав snapshot и persistent cache сохраняются. + +`v8std.localhost` разрешается браузером в loopback, а внутри сети Compose +служит DNS alias сайта. Сайт подключён к внутренней сети `corpus` и обычной +сети `publish`, чтобы Docker Desktop мог опубликовать loopback ports. MCP +подключён только к `corpus`; его HTTP доступен через proxy статического сервера +на отдельном loopback port. Docker socket внутри MCP отсутствует. + +Можно изменить `V8STD_SITE_PORT` и `V8STD_MCP_PORT`, выбрав свободные порты. +Site port одинаков снаружи и внутри контейнера. Для образа сайта, подготовленного +под `/kb/`, задайте `V8STD_SITE_PREFIX=/kb/`. Один полный site URL одновременно +определяет источник manifest/archive и ссылки в ответах MCP. У него нет +скрытой публичной альтернативы. Префикс образа и настройка Compose должны совпадать. + +Явный `V8STD_MCP_SITE_URL` переопределяет вычисленный локальный URL в Compose: +например, `V8STD_MCP_SITE_URL=http://v8std.localhost:18765/kb/` для доступного +по этому адресу snapshot. Настройка одновременно меняет источник и ссылки, +но не перестраивает сайт и не добавляет MCP сетевой доступ: выбранный URL должен +быть доступен из существующей сети `corpus` и с компьютера пользователя. +Проверьте итоговое значение командой `docker compose -p v8std-local -f delivery/local/compose.yaml --profile mcp config`. + +Перед использованием на другой машине проверьте URL из браузера и контейнера: + +```bash +curl --fail http://v8std.localhost:18765/ai/mcp/v1/manifest.json +docker run --rm --network v8std-local_corpus --read-only --cap-drop ALL \ + --security-opt no-new-privileges --entrypoint python "$V8STD_MCP_IMAGE" \ + -c "import urllib.request; print(urllib.request.urlopen('http://v8std.localhost:18765/ai/mcp/v1/manifest.json', timeout=5).status)" +``` + +На Linux не полагайтесь на автоматическое существование `host.docker.internal`. +Если используете собственный доступный контейнерам host/LAN-сайт, явно проверьте +host-gateway и HTTP; успешное разрешение имени само по себе недостаточно: + +```bash +docker run --rm --add-host host.docker.internal:host-gateway \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --entrypoint python "$V8STD_MCP_IMAGE" \ + -c "import socket,urllib.request; print(socket.gethostbyname('host.docker.internal')); print(urllib.request.urlopen('http://host.docker.internal:18765/ai/mcp/v1/manifest.json',timeout=5).status)" +``` + +Для сайта, опубликованного **только** на `127.0.0.1`, последний HTTP probe +на Linux может не пройти: gateway IP не является host loopback. Не меняйте +bind/DNS автоматически. Основная схема выше использует общий `.localhost` +адрес и внутренний DNS alias; native Linux acceptance проверяется отдельно +от amd64-эмуляции Docker Desktop. + +## HTTP-образ MCP + +Образ запускает HTTP-сервис на порту 8000. Клиент подключается по URL /mcp. +Для локального использования порт публикуют только на 127.0.0.1. +Источник индекса задаётся V8STD_MCP_SITE_URL. Отдельного транспорта для локальной +работы нет. Самостоятельный Compose-сценарий MCP с публичным индексом ещё +требует завершения; текущий Compose выше описывает совместный запуск с сайтом. + +## Проверка разработчиком + +После сборки образов: + +```bash +.venv/bin/python -m dev.checks.check_mcp_container \ + --mcp-image "$V8STD_MCP_IMAGE" --site-image "$V8STD_SITE_IMAGE" \ + --platform linux/arm64 +``` + +Проверяются HTTP, отказ Resources, холодный старт, повторный запуск с кэшем +без сети и завершение контейнера по SIGTERM. Опция --chrome добавляет проверку +браузером. Проверка удаляет только созданные ею контейнеры, сети и тома. diff --git a/spec/contracts/article-html-v1-r0.md b/spec/contracts/article-html-v1-r0.md deleted file mode 100644 index 29eaee0..0000000 --- a/spec/contracts/article-html-v1-r0.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: ARTICLE_HTML -scope: product -version: 1 -revision: 0 -compatibility: backward-compatible -design: design:markdown-fence-rendering -producer: Zensical Markdown renderer -consumers: - - site stylesheets - - browser HTML parser - - accessibility tools - - search highlighting and code-copy controls -requirements: - - MARKDOWN_FENCES_ARE_RENDERED_AS_BLOCKS - - ARTICLE_HTML_REJECTS_LAYOUT_BREAKING_STRUCTURE - - MARKDOWN_BUILD_AND_SERVE_SHARE_RENDERING -governs: - - scripts/v8std_markdown.py - - scripts/check_article_html.py - - scripts/zensical_docs.sh - - zensical.toml - - overrides/ - - docs/assets/stylesheets/extra.css -conformance: - module: tests.test_article_html - command: .venv/bin/python -m unittest tests.test_article_html -v -required_when: implemented -supersedes: [] -deprecates: [] ---- - -# HTML статьи, версия 1.0 - -## Область - -Контракт относится к HTML внутри `article.md-content__inner.md-typeset`, -который renderer передаёт браузеру. Проверяется исходный сериализованный -HTML: автоматическое исправление браузером не считается соответствием. -Это не полный валидатор всех HTML-стандартов и не новый Markdown API. - -## Структура и совместимость - -Fenced-блоки кода расположены вне `p`. В абзацах нет элементов, запрещённых -paragraph/phrasing-содержимым, включая `div`, `pre`, списки, таблицы, -блочные цитаты, заголовки и секционные контейнеры. Проверка должна учитывать -структуру HTML и raw-text/комментарии, а не искать эти слова внутри кода. - -Для статьи с непосредственным `h6`, к которой применяется существующая -двухколоночная CSS-раскладка, непосредственные непустые текстовые узлы -запрещены. Такой текст должен находиться в корректном элементе-контейнере. -Пробелы и комментарии допустимы. Содержимое не скрывается и не переносится -в другую смысловую секцию для прохождения проверки. - -Порядок текста, заголовков и блоков кода, их ID и anchors, href, атрибуты -кодовых блоков, классы и семантика diagnostic chips сохраняются. Новые -обёртки абзацев исправляют структуру без изменения публичных URL и ссылок. -CSS и шаблоны входят в `governs` как потребители контракта; их изменение не -входит в scope этого исправления. - -## Проверка и ошибки - -Планируемый CLI `scripts/check_article_html.py --site site` обходит все -HTML-страницы сборки и сообщает относительный путь, строку/позицию и тип -нарушения; возвращает ненулевой код при нарушении или ошибке чтения. -Отсутствующий каталог или отсутствие проверяемых статей не дают ложный успех. -Проверка read-only и не «ремонтирует» выходные файлы. - -Сначала используются fixtures ошибочной и корректной структуры, затем -свежий полный HTML-корпус. Успех `build --strict` включает этот gate. -Один и тот же renderer используется в `serve`, включая повторную генерацию; -gate публикации не заменяется проверкой предпросмотра или статусом HTTP 200. - -Настоящая ревизия не отменяет `DIAGNOSTIC_CHIP_MARKUP@1.0` и не меняет MCP. -Conformance module появится при реализации; до этого контракт фиксирует -намерение и не должен представляться как уже выполняемое свойство сайта. diff --git a/spec/contracts/diagnostic-chip-markup-v1-r0.md b/spec/contracts/diagnostic-chip-markup-v1-r0.md deleted file mode 100644 index 45f4293..0000000 --- a/spec/contracts/diagnostic-chip-markup-v1-r0.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: DIAGNOSTIC_CHIP_MARKUP -scope: product -version: 1 -revision: 0 -compatibility: backward-compatible -design: design:unified-diagnostic-chips -producer: diagnostic renderers -consumers: - - site stylesheets - - diagnostic JavaScript - - accessibility tools - - human and machine readers -requirements: - - DIAGNOSTIC_IDENTIFIERS_USE_SHARED_CHIPS - - DIAGNOSTIC_CHIPS_ARE_ACCESSIBLE - - GENERATED_DIAGNOSTIC_CHIPS_ARE_IDEMPOTENT -governs: - - scripts/diagnostic_standard_links.py - - docs/assets/ - - docs/diagnostics/ -conformance: - module: tests.test_diagnostics_registry_js - command: .venv/bin/python -m unittest tests.test_diagnostics_registry_js -v -required_when: implemented -supersedes: [] -deprecates: [] ---- - -# Разметка diagnostic chip, версия 1.0 - -## Наблюдаемая граница - -Видимое упоминание идентификатора диагностики оформляется ссылкой с общим -классом `.diagnostic-chip`. Ссылка ведёт на каноническую страницу диагностики, -остаётся доступной с клавиатуры и не зависит от JavaScript для навигации. - -Группа обратных ссылок использует семантический контейнер с доступным именем, -но не добавляет отдельный видимый заголовок «Проверки». Генератор распознаёт -уже созданные chip-ссылки и не создаёт вложенную или повторную обёртку. - -## Совместимость - -Новые необязательные CSS-классы и визуальные уточнения обратно совместимы. -Изменение семантики ссылки, канонического target или обязательного базового -класса требует новой major-версии. diff --git a/spec/contracts/diagnostic-relation-graph-v1-r0.md b/spec/contracts/diagnostic-relation-graph-v1-r0.md deleted file mode 100644 index 29b5fb0..0000000 --- a/spec/contracts/diagnostic-relation-graph-v1-r0.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: DIAGNOSTIC_RELATION_GRAPH -scope: product -version: 1 -revision: 0 -compatibility: backward-compatible -design: design:diagnostics-by-standard-clause -producer: reviewed relation registries and relationship generator -consumers: - - standard pages - - diagnostic pages - - diagnostic registry -requirements: - - DIAGNOSTICS_GROUP_BY_STANDARD_CLAUSE - - EMPTY_DIAGNOSTIC_CLAUSES_ARE_OPT_IN - - DIAGNOSTIC_REGISTRY_WORKS_WITHOUT_JAVASCRIPT - - CONFIRMED_DIAGNOSTIC_RELATIONS_ARE_LOSSLESS - - DIAGNOSTIC_CLAUSE_TEXT_IS_DERIVED -governs: - - data/diagnostic-standard-links.json - - scripts/generate_diagnostic_standard_links.py - - docs/diagnostics/ -conformance: - module: tests.test_diagnostic_standard_links - command: .venv/bin/python -m unittest tests.test_diagnostic_standard_links -v -required_when: implemented -supersedes: [] -deprecates: [] ---- - -# Граф связей диагностик и стандартов, версия 1.0 - -## Наблюдаемая граница - -Реестры подтверждённых связей и генератор формируют один детерминированный -граф. Каждое ребро связывает канонический идентификатор диагностики с -существующей страницей и пунктом стандарта. Прямая проекция используется на -страницах диагностик, обратная — на страницах стандартов и в реестре. - -Порядок страниц, пунктов и диагностик стабилен. Краткий текст пункта извлекается -из канонического Markdown. Неизвестная страница, отсутствующий пункт, -противоречащая дублирующая запись или потеря подтверждённого ребра являются -ошибкой генерации, а не поводом молча пропустить данные. - -## Совместимость - -Добавление нового подтверждённого ребра или необязательного производного поля -обратно совместимо. Переименование ключей, изменение идентичности ребра или -удаление подтверждённой связи требует новой major-версии контракта. diff --git a/spec/contracts/mcp-api-v2-r0.md b/spec/contracts/mcp-api-v2-r0.md deleted file mode 100644 index 44bbdb2..0000000 --- a/spec/contracts/mcp-api-v2-r0.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_API -scope: product -version: 2 -revision: 0 -compatibility: backward-compatible -design: design:mcp-v3-resource-contract -producer: MCP v2 /mcp -consumers: - - existing MCP clients -requirements: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_VERSIONS_FAIL_INDEPENDENTLY -governs: - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_index.py -conformance: - module: tests.test_v8std_mcp_server - command: .venv/bin/python -m unittest tests.test_v8std_mcp_server tests.test_v8std_mcp_index -v -required_when: accepted -supersedes: [] -deprecates: [] ---- - -# MCP API v2.0 - -## Endpoint и возможности - -Streamable HTTP endpoint `/mcp` остаётся stateless и предоставляет tools -`v8std_search`, `v8std_get_page`, `v8std_get_related`, -`v8std_explain_snippet`, `v8std_explain_diagnostics`. Он также сохраняет -агрегированные Resources `llms.txt`, `llms-full.txt` и `pages.jsonl`. - -MCP v3 не меняет tool names, input schemas, result shapes, endpoint или -refresh-поведение v2. Ошибка отдельного v3 process не является допустимой -причиной недоступности `/mcp`. - -## Совместимость - -Добавление необязательных полей, принимаемых существующими клиентами, требует -новой revision. Удаление `v8std_get_page`, смена endpoint или несовместимое -изменение схемы требует новой major-версии и не выполняется в этом контракте. diff --git a/spec/contracts/mcp-api-v2-r1.md b/spec/contracts/mcp-api-v2-r1.md deleted file mode 100644 index fc7fe60..0000000 --- a/spec/contracts/mcp-api-v2-r1.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_API -scope: product -version: 2 -revision: 1 -compatibility: backward-compatible -design: design:mcp-100k-agent-capacity -producer: MCP v2 /mcp -consumers: - - existing MCP clients -requirements: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_POST_ONLY_AGENT_TRANSPORT - - MCP_AGENT_REQUESTS_SCALE_HORIZONTALLY - - MCP_OVERLOAD_RETURNS_RETRYABLE_STATUS -governs: - - scripts/v8std_mcp_server.py - - deploy/nginx/server-v8std-mcp.conf - - docs/mcp.md - - tests/test_v8std_mcp_server.py - - tests/test_v8std_mcp_capacity.py -conformance: - module: tests.test_v8std_mcp_server - command: .venv/bin/python -m unittest tests.test_v8std_mcp_server tests.test_v8std_mcp_capacity -v -required_when: implemented -supersedes: - - contract:MCP_API@2.0 -deprecates: [] ---- - -# MCP API v2.1 - -The `/mcp` endpoint preserves the v2 tools, inputs, JSON-RPC POST lifecycle, -resources, and browser self-documentation. Because the server is stateless and -does not send unsolicited server-to-client messages, `GET /mcp` requests that -offer `text/event-stream` receive `405 Method Not Allowed` with -`Allow: POST, HEAD`. This is the transport-level capacity clarification; it -does not remove or rename any v2 tool. - -Admission overload is represented by retryable 429/503 responses at the edge; -upstream 502/504 failures are normalized to 503, rather than exposing an -nginx 500 caused by exhausted worker connections. diff --git a/spec/contracts/mcp-api-v2-r2.md b/spec/contracts/mcp-api-v2-r2.md deleted file mode 100644 index ab99621..0000000 --- a/spec/contracts/mcp-api-v2-r2.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_API -scope: product -version: 2 -revision: 2 -compatibility: backward-compatible -design: design:mcp-combined-endpoint -producer: MCP combined runtime /mcp -consumers: - - existing MCP v2 clients - - resource-capable MCP clients -requirements: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_COMBINED_ENDPOINT_CAPABILITIES - - MCP_COMBINED_PAGE_READING_COMPATIBLE -governs: - - scripts/v8std_mcp_server.py - - deploy/nginx/server-v8std-mcp.conf - - deploy/systemd/v8std-mcp.service - - docs/mcp.md - - tests/test_v8std_mcp_server.py - - tests/test_v8std_mcp_combined.py -conformance: - module: tests.test_v8std_mcp_combined - command: .venv/bin/python -m unittest tests.test_v8std_mcp_server tests.test_v8std_mcp_combined -v -required_when: implemented -supersedes: - - contract:MCP_API@2.1 -deprecates: - - contract:MCP_API@3.0 ---- - -# MCP API v2.2 combined profile - -The public contract has one endpoint, `/mcp`, and one runtime. The five v2 -tools, their names, inputs, results, and `v8std_get_page` remain available. - -MCP Resources are an additive capability profile on the same endpoint. A -resource-capable client may use `resources/list` and `resources/read`; a -tool-only v2 client continues to use `v8std_get_page`. No application-version -negotiation or client metadata heuristic changes the tool catalog. - -The separate future `/v3/mcp` contract is deprecated before implementation and -must not be deployed. diff --git a/spec/contracts/mcp-api-v2-r3.md b/spec/contracts/mcp-api-v2-r3.md deleted file mode 100644 index d7b4509..0000000 --- a/spec/contracts/mcp-api-v2-r3.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_API -scope: product -version: 2 -revision: 3 -compatibility: backward-compatible -design: design:mcp-large-procedure-retrieval -producer: MCP combined runtime /mcp -consumers: - - existing MCP v2 clients - - coding agents using tools/list and tools/call - - operators of local Python and Docker MCP instances - - resource-capable MCP clients -requirements: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_COMBINED_ENDPOINT_CAPABILITIES - - MCP_COMBINED_PAGE_READING_COMPATIBLE - - MCP_SNIPPET_ACCEPTED_INPUT_IS_SCANNED - - MCP_SNIPPET_TARGETS_SURVIVE_QUERY_BUDGET - - MCP_SNIPPET_INSTANCE_LIMIT_IS_DISCOVERABLE - - MCP_SNIPPET_RETRIEVAL_WORK_IS_BOUNDED - - MCP_SNIPPET_RESPONSE_STAYS_COMPACT -governs: - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_index.py - - scripts/v8std_retrieval_rules.py - - scripts/run_v8std_mcp.sh - - docker-compose/docker-compose.yml - - deploy/nginx/server-v8std-mcp.conf - - deploy/systemd/v8std-mcp.service - - docs/mcp.md - - docs/support.md - - tests/test_v8std_mcp_server.py - - tests/test_v8std_mcp_index.py - - tests/test_v8std_mcp_snippet.py - - tests/test_v8std_mcp_combined.py -conformance: - module: tests.test_v8std_mcp_snippet - command: .venv/bin/python -m unittest tests.test_v8std_mcp_snippet tests.test_v8std_mcp_server tests.test_v8std_mcp_index tests.test_v8std_mcp_combined -v -required_when: implemented -supersedes: - - contract:MCP_API@2.2 -deprecates: - - contract:MCP_API@3.0 ---- - -# MCP API v2.3: bounded snippet retrieval - -## Сохранённая граница - -Один `/mcp`, прежние пять tool names, обязательные аргументы и типы полей -ответов сохраняются, в том числе `v8std_get_page`. Additive Resources-профиль -из v2.2 не удаляется и не расширяется этим уточнением; второй `/v3/mcp` не -появляется. Номер этого внутреннего contract не меняет MCP protocol negotiation -или строку application API version в `/version`. - -## Ввод и discovery - -`v8std_explain_snippet.snippet` по умолчанию допускает до 4000 символов исходной -декодированной строки; локальный экземпляр может быть явно настроен до 32000. -Предел фиксируется при запуске, указан в `tools/list` как `maxLength` и в -описании инструмента и совпадает с фактической проверкой индекса. Внешний -текстовый `v8std_search.query` сохраняет предел 500. - -Правила разрешения CLI/env, диапазон, ошибки конфигурации и способы запуска -нормативно заданы в `design:mcp-large-procedure-retrieval`. Превышение размера -даёт MCP tool error с эффективным пределом без исходного текста; direct index -даёт ValueError. Точная SDK-обёртка сообщения не фиксируется. Невалидный ввод не -обрезается до допустимого; транспортный byte-limit является отдельной границей. - -## Результат - -Сохраняются поля `language`, `normalized_text`, `tokens`, `signals`, -`diagnostics`, `standards`, `confidence`. Preview ограничен 1000 символами; -уникальные токены — 80 элементами и суммарно 4000 символами без подрезки -отдельного токена. Идентичные сигналы выдаются один раз, не как счётчик -повторений; различные сигналы сохраняются. - -Распознанные первичные цели имеют приоритет над дополнительными и текстовыми -рекомендациями. Общий top-K делится на прежние два массива; сумма их длин -не превышает `limit` с прежними default 10 и maximum 50. Место каждой цели -гарантируется только когда K вмещает все существующие первичные цели. -Порядок/score snippet-рекомендаций может измениться как исправление retrieval; -новый числовой `score_details.snippet_signal` и `match_reasons` объясняют вклад. -Обычный поиск и его ранжирование от настройки snippet не меняются. - -`confidence` остаётся ограниченной 0..1 эвристикой, не вероятностью нарушения. -Ответ не включает новые полные статьи или полный исходный код. Usage-лог не -получает исходную процедуру, литералы или полный tool error с входным payload. - -## Совместимость и доказательства - -Revision совместима по действующим tool arguments/result shape и сохраняет -прежний допустимый default-ввод; расширение окна только явное. Новое schema -ограничение описывает уже существующую runtime-границу. Удаление повторов -SDBL уточняет set-like семантику `signals`, уже применявшуюся к вызовам; ни -позиции, ни кратность нарушения этот API не обещает. - -Wire conformance проверяет discovery и вызовы одного и того же экземпляра -для default/override, ошибки, оба JSON-представления Unicode и неизменные -legacy tools. Полная матрица приёмки находится в design. Conformance-декларация -сама по себе не свидетельствует о выполненной реализации; результаты -проверок находятся в [verification evidence](../operations/2026-09-10-mcp-snippet-verification.md). diff --git a/spec/contracts/mcp-api-v3-r0.md b/spec/contracts/mcp-api-v3-r0.md deleted file mode 100644 index 1e7789f..0000000 --- a/spec/contracts/mcp-api-v3-r0.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_API -scope: product -version: 3 -revision: 0 -compatibility: breaking -design: design:mcp-v3-resource-contract -producer: future MCP v3 /v3/mcp -consumers: - - resource-capable MCP clients -requirements: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_VERSIONS_FAIL_INDEPENDENTLY - - MCP_RESOURCE_VERSION_PAGE_READING_USES_RESOURCES - - MCP_RESOURCE_VERSION_HAS_ONE_PRIMARY_PAGE_READER - - MCP_RESOURCE_CATALOG_IS_PAGINATED - - MCP_RESOURCE_LIST_USES_STABLE_SNAPSHOTS - - MCP_RESOURCE_NOTIFICATIONS_ARE_OMITTED - - MCP_RESOURCE_LINKS_RESOLVE_TO_LISTED_RESOURCES - - MCP_RESOURCES_EXCLUDE_SUPPORT_PAGES - - MCP_TEMPLATES_EXCLUDE_LANGUAGE_AND_METHOD_SOURCES -governs: - - scripts/v8std_mcp_v3.py - - scripts/v8std_mcp_resources.py -conformance: - module: tests.test_v8std_mcp_v3 - command: .venv/bin/python -m unittest tests.test_v8std_mcp_v3 -v -required_when: implemented -supersedes: [] -deprecates: [] ---- - -# MCP API v3.0 - -## Endpoint и tools - -Streamable HTTP endpoint `/v3/mcp` запускается отдельно от `/mcp`. Он -предоставляет `v8std_search`, `v8std_get_related`, `v8std_explain_snippet` и -`v8std_explain_diagnostics`; `v8std_get_page` отсутствует. Page content -читается только через Resources. - -## URI и templates - -URI используют префикс `v8std://ru/`. Templates существуют только для: - -```text -v8std://ru/standards/{number} -v8std://ru/diagnostics/{family}/{code} -v8std://ru/patterns/{family} -v8std://ru/patterns/{family}/{slug} -``` - -`lang` и конкретные страницы `metod8dev` могут быть listable Resources, но не -templates. Страницы `mcp`, `search_help` и `support` отсутствуют в list/read, -не имеют `resource_uri` и не возвращаются как resource links. Универсальный -`v8std://page/{id}` запрещён. - -## List, snapshot и cursor - -`resources/list` возвращает не более 200 дескрипторов, отсортированных по URI. -Первый запрос фиксирует immutable snapshot и revision, вычисленную из версии -resource schema, SHA индекса и visibility policy. Opaque Base64URL cursor -содержит только версию формата, revision и offset. Он продолжает тот же -snapshot; неверный, устаревший или относящийся к другому revision cursor -возвращает protocol error, а не начинает список заново. - -Refresh создаёт новый snapshot только для нового list. Уже выданные cursors -остаются привязаны к прежнему snapshot в пределах установленного срока жизни. -Resource list-change notifications и subscriptions не объявляются. - -## Read и resource links - -`resources/read` принимает только canonical URI видимой страницы, возвращает -полный Markdown с `mimeType: text/markdown; charset=utf-8` и не читает support -pages. Каждый `resource_link` из tool result ссылается на URI, присутствующий в -listable snapshot и разрешимый тем же read contract. - -## Совместимость - -Версия 3 breaking относительно v2 из-за удаления page tool и смены resource -model, но не заменяет `MCP_API@2.0`: версии живут на разных endpoint. diff --git a/spec/contracts/mcp-monitoring-projection-v1-r0.md b/spec/contracts/mcp-monitoring-projection-v1-r0.md deleted file mode 100644 index f326ab0..0000000 --- a/spec/contracts/mcp-monitoring-projection-v1-r0.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_MONITORING_PROJECTION -scope: product -version: 1 -revision: 0 -compatibility: backward-compatible -design: design:mcp-monitoring-dashboard -producer: current monitoring aggregator -consumers: - - public legacy monitoring JSON and page -requirements: - - MONITORING_REMAINS_PUBLIC - - LEGACY_USAGE_EVENTS_REMAIN_READABLE -governs: - - scripts/v8std_mcp_monitoring.py -conformance: - module: tests.test_v8std_mcp_monitoring - command: .venv/bin/python -m unittest tests.test_v8std_mcp_monitoring -v -required_when: accepted -supersedes: [] -deprecates: [] ---- - -# MCP monitoring projection v1.0 - -## Legacy projection - -Текущий aggregator продолжает формировать существующие JSON/static artifacts и -публичную страницу `/monitoring/` из legacy events. Их поля и readers -сохраняются до отдельного rollout v2 projection, чтобы текущий сайт не зависел -от design-only редизайна. - -Контракт фиксирует только существующую совместимость. Он не разрешает считать -сырые query, IP или user-agent безопасными для новой публичной проекции. diff --git a/spec/contracts/mcp-monitoring-projection-v2-r0.md b/spec/contracts/mcp-monitoring-projection-v2-r0.md deleted file mode 100644 index bb30be1..0000000 --- a/spec/contracts/mcp-monitoring-projection-v2-r0.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_MONITORING_PROJECTION -scope: product -version: 2 -revision: 0 -compatibility: breaking -design: design:mcp-monitoring-dashboard -producer: redesigned monitoring aggregator -consumers: - - public monitoring dashboard - - local SSH operator view -requirements: - - MONITORING_SHOWS_AGENT_FAMILIES - - MONITORING_SHOWS_API_VERSIONS - - MONITORING_SHOWS_MCP_OPERATIONS - - PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA - - OPERATOR_DETAILS_STAY_OUTSIDE_WEB_ROOT - - MONITORING_REMAINS_PUBLIC -governs: - - scripts/v8std_mcp_monitoring.py -conformance: - module: tests.test_v8std_mcp_monitoring - command: .venv/bin/python -m unittest tests.test_v8std_mcp_monitoring.MonitoringProjectionV2Tests -v -required_when: implemented -supersedes: [] -deprecates: [] ---- - -# MCP monitoring projection v2.0 - -## Публичная проекция - -`/monitoring/` показывает агрегаты за фиксированные окна: семейства агентов, -API v2/v3, классы MCP operations, нормализованные tool names, outcomes и -популярность публичных материалов. Unknown остаётся явной категорией. - -Публичные JSON/HTML не содержат IP, raw user-agent/clientInfo, request ID, -cursor, query text, tool arguments, произвольный resource URI или группы ниже -порогов раскрытия. Генератор атомарно заменяет только успешно построенный -artifact; при ошибке Nginx отдаёт предыдущую исправную версию. - -## Операторская проекция - -Расширенный отчёт и raw/restricted inputs находятся вне web root с правами не -шире `0640`. Внешний HTTP route для них отсутствует; получение выполняется -оператором через SSH/SCP. - -Версия 2 breaking по JSON/data model относительно legacy projection, но не -удаляет v1 artifact до отдельного rollout и подтверждённого rollback path. diff --git a/spec/contracts/mcp-openmetrics-v1-r0.md b/spec/contracts/mcp-openmetrics-v1-r0.md deleted file mode 100644 index bb2f458..0000000 --- a/spec/contracts/mcp-openmetrics-v1-r0.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_OPENMETRICS -scope: product -version: 1 -revision: 0 -compatibility: backward-compatible -design: design:mcp-openmetrics-generation -producer: MCP v2 and v3 metric registries -consumers: - - future unified Prometheus -requirements: - - OPENMETRICS_IS_GENERATED_LOCALLY - - PROMETHEUS_INTEGRATION_IS_DEFERRED - - METRICS_EXCLUDE_HIGH_CARDINALITY_LABELS - - METRICS_ENDPOINTS_USE_LOOPBACK - - METRIC_NAMES_ARE_VERSIONED_CONTRACTS -governs: - - scripts/v8std_mcp_metrics.py - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_v3.py -conformance: - module: tests.test_v8std_mcp_metrics - command: .venv/bin/python -m unittest tests.test_v8std_mcp_metrics -v -required_when: implemented -supersedes: [] -deprecates: [] ---- - -# MCP OpenMetrics v1.0 - -## Exposition - -Каждый MCP process отдаёт `/metrics` только на loopback с content type -`application/openmetrics-text; version=1.0.0; charset=utf-8`, `Cache-Control: -no-store`, LF и завершающим `# EOF`. Публичный Nginx не proxy-ирует endpoint. - -Обязательные metric families: - -```text -v8std_mcp_info{api_version,resource_schema} -v8std_mcp_operations_total{api_version,method,outcome} -v8std_mcp_operation_duration_seconds{api_version,method} -v8std_mcp_operations_in_progress{api_version,method} -v8std_mcp_tool_calls_total{api_version,tool,outcome} -v8std_mcp_resource_reads_total{api_version,resource_type,outcome} -v8std_mcp_resource_list_requests_total{api_version,outcome} -v8std_mcp_resource_list_items_total{api_version} -v8std_mcp_catalog_rows{api_version} -v8std_mcp_catalog_resources{api_version} -v8std_mcp_catalog_refresh_total{api_version,outcome} -v8std_mcp_catalog_last_success_timestamp_seconds{api_version} -v8std_mcp_catalog_degraded{api_version} -v8std_mcp_agent_operations_total{api_version,agent_family,operation_class} -``` - -Method, outcome, resource type, agent family и operation class используют -закрытые нормализованные множества с `other`/`unknown`. Query, arguments, -page/title/URL, resource URI, cursor, diagnostic code, IP, raw client identity, -request ID, build SHA и revision запрещены как labels. - -Появление новой metric family, переименование или изменение смысла labels -требует revision либо major-версию по совместимости. Scrape configuration, -retention, dashboard и alerts не входят в этот контракт. diff --git a/spec/contracts/mcp-usage-events-v1-r0.md b/spec/contracts/mcp-usage-events-v1-r0.md deleted file mode 100644 index bfd30e0..0000000 --- a/spec/contracts/mcp-usage-events-v1-r0.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_USAGE_EVENTS -scope: product -version: 1 -revision: 0 -compatibility: backward-compatible -design: design:mcp-monitoring-dashboard -producer: current MCP logger and Nginx -consumers: - - current monitoring aggregator -requirements: [LEGACY_USAGE_EVENTS_REMAIN_READABLE] -governs: - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_monitoring.py -conformance: - module: tests.test_v8std_mcp_monitoring - command: .venv/bin/python -m unittest tests.test_v8std_mcp_server tests.test_v8std_mcp_monitoring -v -required_when: accepted -supersedes: [] -deprecates: [] ---- - -# MCP usage events v1.0 - -## Legacy event - -Текущий JSONL event может не содержать `schema_version` и `api`. Наличие `tool` -идентифицирует legacy v2 tool call; `ts`, `tool`, публичный `page_id` и -агрегируемые числовые поля читаются текущим aggregator. Неизвестное или -отсутствующее новое измерение нормализуется в `unknown`, а не делает строку -нечитаемой. - -Этот контракт сохраняется для исторических файлов и сквозных окон. Новые -emitters не обязаны продолжать создавать v1 после появления versioned v2 event. diff --git a/spec/contracts/mcp-usage-events-v2-r0.md b/spec/contracts/mcp-usage-events-v2-r0.md deleted file mode 100644 index 5a274e1..0000000 --- a/spec/contracts/mcp-usage-events-v2-r0.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: MCP_USAGE_EVENTS -scope: product -version: 2 -revision: 0 -compatibility: backward-compatible -design: design:mcp-monitoring-dashboard -producer: future MCP v2 and v3 event emitters -consumers: - - redesigned monitoring aggregator -requirements: - - MONITORING_SHOWS_AGENT_FAMILIES - - MONITORING_SHOWS_API_VERSIONS - - MONITORING_SHOWS_MCP_OPERATIONS - - PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA - - OPERATOR_DETAILS_STAY_OUTSIDE_WEB_ROOT - - LEGACY_USAGE_EVENTS_REMAIN_READABLE -governs: - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_v3.py - - scripts/v8std_mcp_monitoring.py -conformance: - module: tests.test_v8std_mcp_monitoring - command: .venv/bin/python -m unittest tests.test_v8std_mcp_monitoring.MonitoringEventV2Tests -v -required_when: implemented -supersedes: [] -deprecates: [] ---- - -# MCP usage events v2.0 - -## Envelope - -Каждый JSONL event содержит `schema_version: 2`, ISO 8601 `ts`, `api` (`v2` или -`v3`), `kind`, `method`, `outcome`, нормализованный `agent_family` и -`agent_source`. Один JSON-RPC request создаёт не более одного usage event. - -`kind` ограничен `initialize`, `mcp_operation`, `content_usage`; `outcome` — -`success`, `client_error`, `server_error`. Неизвестные method/agent -нормализуются в `other`/`unknown`, а не создают новую категорию. - -Tool event может содержать только опубликованное имя `tool`, но не arguments. -Resource read может хранить тип и идентификаторы уже публичной страницы, но не -Markdown. Resource list хранит `page_size`, `result_count` и факт наличия -cursor, но не cursor и не состав страницы. - -Исходные clientInfo, user-agent, IP, request ID и query text не записываются в -usage event. Search feedback хранится отдельным restricted потоком вне web root. - -## Совместимость - -Aggregator v2 читает одновременно v1 и v2. Сохранение v1 reader обязательно; -v2 не требует переписывать исторические JSONL-файлы. diff --git a/spec/contracts/standard-source-registry-v1-r0.md b/spec/contracts/standard-source-registry-v1-r0.md deleted file mode 100644 index eb5e553..0000000 --- a/spec/contracts/standard-source-registry-v1-r0.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -schema_version: 1 -kind: contract -id: STANDARD_SOURCE_REGISTRY -scope: product -version: 1 -revision: 0 -compatibility: backward-compatible -design: design:english-standard-sources -producer: data/standard-english-sources.json maintainers -consumers: - - scripts/standard_sources.py - - source validation tests - - documentation and AI artifact generators -requirements: - - ENGLISH_STANDARD_LINKS_REQUIRE_VERIFICATION - - RUSSIAN_STANDARD_SOURCE_REMAINS_PRIMARY - - STANDARD_SOURCE_REGISTRY_IS_DETERMINISTIC - - STANDARD_SOURCE_VALIDATION_IS_OFFLINE -governs: - - data/standard-english-sources.json - - scripts/standard_sources.py - - docs/std/ -conformance: - module: tests.test_standard_sources - command: .venv/bin/python -m unittest tests.test_standard_sources -v -required_when: implemented -supersedes: [] -deprecates: [] ---- - -# Реестр источников стандартов, версия 1.0 - -## Наблюдаемая граница - -Реестр является отсортированным отображением канонического ID страницы -стандарта в проверенный англоязычный HTTPS URL 1Ci. ID обязан разрешаться в -существующий `docs/std/.md`, а страница обязана сохранять соответствующий -русский ITS URL первым источником. - -Одна страница и один URL не могут иметь противоречащие записи. Отсутствие -проверенного английского соответствия допустимо и не создаёт placeholder. -Проверка схемы, уникальности и синхронизации Markdown выполняется офлайн; -повторная запись не изменяет файлы. - -## Совместимость - -Добавление новой проверенной пары обратно совместимо. Изменение смысла ключа, -разрешение неподтверждённых URL или удаление обязательного русского источника -требует новой major-версии. diff --git a/spec/delivery-target.md b/spec/delivery-target.md new file mode 100644 index 0000000..bbdf43f --- /dev/null +++ b/spec/delivery-target.md @@ -0,0 +1,123 @@ +# Целевая задача поставки v8std + +Зафиксировано 16 сентября 2026 года. Документ описывает согласованный целевой +результат, а не подтверждает готовность текущей реализации. +Решение об HTTP уточнено 17 сентября. Текущее состояние и оставшиеся работы — +в [плане поставки](repository-layout-plan.md). + +## Результат + +Выпустить сайт и MCP с чистой переустановкой существующего VPS и обеспечить +три варианта локального запуска. Решение пользователя от 17 сентября заменяет +прежнее требование отдельного нового VPS: существующий сервер переустанавливается +после подготовки резервной копии и проверенных артефактов. + +В публичных текстах и схемах использовать обозначения `v8std.ru` (GitHub Pages) +и `ai.v8std.ru` (VPS), без названия провайдера VPS. + +| Результат поставки | Размещение и назначение | +|---|---| +| Статический сайт | `v8std.ru` — GitHub Pages | +| Собранный поисковый индекс | `ai.v8std.ru` — VPS; доступен для скачивания локальным MCP | +| MCP-сервис | `ai.v8std.ru` — VPS | +| Docker-образ сайта с индексом | Самостоятельный локальный сайт или совместный запуск с MCP | +| Docker-образ MCP | Локальный запуск; тот же образ используется для MCP на VPS | + +## Схема поставки + +```mermaid +flowchart LR + subgraph CONTENT_DELIVERY["Сайт и индекс"] + SOURCE["docs/, код сайта,
    шаблоны и зависимости"] + BUILD["Сборка сайта и индекса
    одной версии контента"] + PAGES["Статический сайт
    v8std.ru — GitHub Pages"] + VPSINDEX["Поисковый индекс
    ai.v8std.ru — VPS"] + SITEIMAGE["Docker-образ сайта
    с тем же индексом"] + LOCALSITE["Локальный сайт"] + + SOURCE --> BUILD + BUILD -->|"Страницы"| PAGES + BUILD -->|"Индекс"| VPSINDEX + BUILD -->|"Страницы и индекс"| SITEIMAGE + SITEIMAGE --> LOCALSITE + end + + subgraph MCP_DELIVERY["MCP"] + MCPSOURCE["Код MCP и зависимости"] + MCPIMAGE["Сборка Docker-образа MCP"] + VPSMCP["MCP-сервис
    ai.v8std.ru — VPS"] + LOCALMCP["Локальный MCP"] + + MCPSOURCE --> MCPIMAGE + MCPIMAGE --> VPSMCP + MCPIMAGE --> LOCALMCP + end +``` + +Индекс собирается один раз для версии контента. Один и тот же артефакт индекса +поставляется на VPS и включается в Docker-образ сайта. Страницы локального сайта +и его индекс относятся к одной версии контента. + +## Схема развёртывания + +```mermaid +flowchart TB + subgraph PUBLIC["Публичное развёртывание"] + BROWSER["Браузер"] --> PAGES["v8std.ru: сайт на GitHub Pages"] + CLIENT["MCP-клиент"] --> MCP["ai.v8std.ru — VPS: MCP"] + MCP --> INDEX["ai.v8std.ru — VPS: индекс"] + end + + subgraph SITEONLY["Локально: только сайт"] + B1["Браузер"] --> S1["Docker сайта; MCP не требуется"] + end + + subgraph MCPONLY["Локально: только MCP"] + C2["MCP-клиент"] --> M2["Docker MCP"] + M2 -->|"Скачивает индекс"| INDEX + end + + subgraph BOTH["Локально: сайт и MCP"] + B3["Браузер"] --> S3["Docker сайта: страницы и индекс"] + C3["MCP-клиент"] --> M3["Docker MCP"] + M3 -->|"Получает индекс по внутренней сети"| S3 + end +``` + +## Правила локального запуска + +MCP использует только Streamable HTTP, включая локальный запуск. +Поддержка stdio и поставка через Docker MCP Catalog/Gateway исключены. + +- Сайт запускается и работает без MCP. +- MCP без локального сайта скачивает индекс с `ai.v8std.ru`. +- При совместном запуске MCP получает индекс из контейнера локального сайта. + Контейнер сайта предоставляет индекс для скачивания по внутренней сети. +- Источник индекса задаётся настройкой MCP; отдельные образы MCP для разных + источников индекса не нужны. +- Один образ сайта используется как отдельно, так и совместно с MCP. +- Если выбранный локальный источник индекса недоступен, MCP сообщает явную + ошибку и не переключается незаметно на публичный индекс. + +## Критерии готовности + +- Сайт опубликован и доступен на `v8std.ru`. +- Серверная часть установлена на чистой ОС после переустановки существующего VPS. Индекс доступен для + скачивания, MCP доступен клиентам через `ai.v8std.ru`. +- Самостоятельный локальный сайт запускается из поставляемого Docker-образа + и открывается в браузере без запущенного MCP. +- Самостоятельный локальный MCP скачивает опубликованный индекс с + `ai.v8std.ru` и обслуживает запросы MCP-клиента. +- При совместном локальном запуске сайт открывается в браузере, а MCP + обслуживает запросы по индексу локального сайта. Источник индекса проверен. +- Недоступность локального источника индекса приводит к явной ошибке, + без скрытого перехода на публичный источник. +- Подтверждено совпадение артефакта индекса на VPS и в образе сайта для одной + поставки, а также соответствие индекса версии контента. +- До переустановки сохранена внешняя резервная копия и определён способ восстановления + прежней установки. На время переустановки допустим согласованный простой MCP; + после установки проверена работа через публичный адрес. + +Готовность подтверждается фактическими проверками этих сценариев. Наличие +этого документа само по себе не означает выполнение задачи. Конкретные команды +сборки, адреса скачивания индекса и настройки запуска уточняются по реализации. diff --git a/spec/designs/2026-07-22-diagnostics-by-standard-clause-design.md b/spec/designs/2026-07-22-diagnostics-by-standard-clause-design.md deleted file mode 100644 index 854d70f..0000000 --- a/spec/designs/2026-07-22-diagnostics-by-standard-clause-design.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -schema_version: 1 -kind: design -id: diagnostics-by-standard-clause -scope: product -requirements: - introduces: - - DIAGNOSTICS_GROUP_BY_STANDARD_CLAUSE - - EMPTY_DIAGNOSTIC_CLAUSES_ARE_OPT_IN - - DIAGNOSTIC_REGISTRY_WORKS_WITHOUT_JAVASCRIPT - - CONFIRMED_DIAGNOSTIC_RELATIONS_ARE_LOSSLESS - - DIAGNOSTIC_CLAUSE_TEXT_IS_DERIVED - uses: [] - replaces: {} - cancels: [] -decisions: [] -invariants: [] -contracts: [contract:DIAGNOSTIC_RELATION_GRAPH@1.0] -supersedes: [] -cancels: [] ---- - -# Реестр диагностик по пунктам стандартов - -## Требования - -### DIAGNOSTICS_GROUP_BY_STANDARD_CLAUSE - -Реестр группирует диагностики сначала по странице стандарта, затем по -конкретному пункту этой страницы и сохраняет количество связей в каждой группе. - -### EMPTY_DIAGNOSTIC_CLAUSES_ARE_OPT_IN - -Пункты без подтверждённых диагностик скрыты по умолчанию и показываются только -после явного действия пользователя. - -### DIAGNOSTIC_REGISTRY_WORKS_WITHOUT_JAVASCRIPT - -Содержимое и ссылки реестра доступны в исходном HTML; JavaScript улучшает -фильтрацию и раскрытие, но не является условием чтения данных. - -### CONFIRMED_DIAGNOSTIC_RELATIONS_ARE_LOSSLESS - -Генерация не теряет подтверждённые связи «диагностика — пункт стандарта» и -детерминированно представляет каждую связь в прямой и обратной проекции. - -### DIAGNOSTIC_CLAUSE_TEXT_IS_DERIVED - -Краткий текст пункта стандарта вычисляется из канонического Markdown и не -поддерживается как независимая вручную синхронизируемая копия. - -## Цель - -Сделать страницу `/diagnostics/` пригодной для чтения при большом количестве -диагностик. Вместо плоского списка всех диагностик статьи стандарт раскрывается -до конкретных пунктов, которые проверяет каждая диагностика. - -Связи с пунктами уже зафиксированы в проверенных данных полями `standard`, -`clause` и `anchor`. Изменение должно сохранить эти данные при формировании -реестра, а не создавать параллельный справочник связей. - -## Представление страницы - -Основная единица страницы — раскрывающийся блок стандарта. Его заголовок -содержит номер, название, число уникальных диагностик и число пунктов с -проверками, например: - -```text -#std640 Параметры процедур и функций · 18 проверок · 6 пунктов -``` - -Внутри стандарта диагностики сгруппированы по пунктам. Заголовок группы содержит -номер пункта и, когда его можно надёжно получить, краткое требование: - -```text -п. 5 — Не делайте больше 7 параметров -``` - -Под заголовком перечислены связанные диагностики со ссылками на их страницы. -Номер пункта ведёт на точный якорь страницы стандарта. Одна диагностика может -присутствовать в нескольких группах, если она проверяет несколько требований. -Счётчик проверок в заголовке стандарта при этом считает уникальные диагностики, -а не число отображаемых связей. - -Подтверждённые связи без конкретного пункта показываются отдельной последней -группой «Стандарт в целом». Они не должны искусственно приписываться одному из -пунктов. - -## Скрытие пустых пунктов - -По умолчанию страница показывает только стандарты и пункты, для которых есть -хотя бы одна подтверждённая диагностика. Это устраняет строки «Нет диагностик», -которые сейчас составляют значительную часть реестра. - -Над реестром размещается переключатель «Показать пункты без проверок». После -включения он добавляет: - -- пункты без подтверждённых диагностик внутри показанных стандартов; -- стандарты, в которых нет ни одной подтверждённой диагностики. - -Пустой пункт явно помечается текстом «Нет проверок». Состояние по умолчанию не -зависит от JavaScript: без выполнения скрипта пользователь видит компактный -реестр только с полезными связями. - -## Поиск и раскрытие - -Стандарты реализуются доступными с клавиатуры элементами `
    `. Поиск -фильтрует реестр по: - -- номеру и названию стандарта; -- номеру и краткому тексту пункта; -- идентификатору и названию диагностики. - -При совпадении внутри стандарта его блок раскрывается, а не относящиеся к -запросу пункты скрываются. Очистка поиска возвращает исходное состояние -раскрытия. Поиск учитывает пустые пункты только тогда, когда включён их показ. - -## Получение краткого текста пункта - -Номер и якорь пункта берутся из проверенной связи. Краткий текст извлекается из -соответствующего Markdown-раздела страницы стандарта: - -1. рассматривается содержимое после числового заголовка пункта до следующего - пункта того же или более высокого уровня; -2. выбирается первая самостоятельная содержательная фраза обычного текста; -3. ссылки преобразуются в видимый текст, а служебная разметка удаляется; -4. код, admonition-блоки, HTML-комментарии, диагностические backlinks и списки - «См. также» не используются как заголовок; -5. если надёжную фразу получить нельзя, отображается только номер пункта. - -Текст не редактируется вручную в отдельном реестре. Поэтому изменение -формулировки стандарта автоматически отражается на странице диагностик. - -## Генерация данных - -Генератор реестра строит иерархию -`стандарт → пункт → подтверждённые диагностики`. Источниками остаются: - -- страницы `docs/std/*.md` для заголовков, состава пунктов и краткого текста; -- проверенные связи BSLLS и EDT v8-code-style; -- проверенные связи ACC с учётом существующих overrides. - -Связи со статусом, отличным от `confirmed`, в публичный реестр не попадают. -Сортировка стандартов остаётся числовой. Пункты сортируются по числовым -компонентам (`6.2` после `6.1`, а не после `6.10`); группа «Стандарт в целом» -идёт последней. Диагностики внутри пункта используют существующий стабильный -порядок по семейству и идентификатору. - -Генератор выдаёт самодостаточную разметку для основного состояния страницы и -структурированные атрибуты для фильтрации и показа пустых элементов. Небольшой -скрипт отвечает только за интерактивность; он не содержит копии каталога. - -## Ошибки и целостность - -Подтверждённая связь с указанным пунктом обязана разрешаться в существующий -числовой заголовок и якорь страницы стандарта. Отсутствующий стандарт, пункт или -якорь является ошибкой генерации и строгой сборки. Такая связь не переносится -молча в группу «Стандарт в целом». - -Связь со стандартом в целом представляется существующим инвариантом -`clause="stdNNN"` и `anchor="stdNNN"`. Пустые `clause` и `anchor` допустимы -только у отклонённых предложений и не попадают в публичный реестр. Частично -заполненная пара `clause`/`anchor` считается ошибкой данных. - -Ни одна подтверждённая связь не должна теряться. Генератор сравнивает множество -входных связей с множеством выведенных связей, учитывая допустимое повторение -одной диагностики в разных пунктах. - -## Проверка - -Автоматические тесты покрывают: - -1. группировку нескольких диагностик по одному пункту; -2. одну диагностику, связанную с несколькими пунктами; -3. группу «Стандарт в целом»; -4. числовую сортировку вложенных пунктов; -5. извлечение краткой фразы и безопасный fallback только к номеру; -6. показ и скрытие пустых пунктов и стандартов; -7. фильтрацию по стандарту, пункту и диагностике; -8. точные ссылки на якоря; -9. ошибку для отсутствующего или частично заданного пункта; -10. равенство входного и выведенного множеств подтверждённых связей. - -Готовность подтверждается тестами генератора, `git diff --check` и строгой -сборкой Zensical. Итоговую локальную страницу следует проверить в широком и -мобильном окне, с клавиатуры и с отключённым JavaScript. - -## За пределами задачи - -- пересмотр уже подтверждённых соответствий диагностик пунктам; -- изменение отдельных страниц диагностик или backlinks в стандартах; -- создание новых страниц на каждый стандарт; -- изменение реестров MCP и AI-артефактов, если их содержимое не зависит от - представления `docs/diagnostics/index.md`; -- публикация сайта. diff --git a/spec/designs/2026-07-22-english-standard-sources-design.md b/spec/designs/2026-07-22-english-standard-sources-design.md deleted file mode 100644 index 81ff698..0000000 --- a/spec/designs/2026-07-22-english-standard-sources-design.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -schema_version: 1 -kind: design -id: english-standard-sources -scope: product -requirements: - introduces: - - ENGLISH_STANDARD_LINKS_REQUIRE_VERIFICATION - - RUSSIAN_STANDARD_SOURCE_REMAINS_PRIMARY - - STANDARD_SOURCE_REGISTRY_IS_DETERMINISTIC - - STANDARD_SOURCE_VALIDATION_IS_OFFLINE - uses: [] - replaces: {} - cancels: [] -decisions: [] -invariants: [] -contracts: [contract:STANDARD_SOURCE_REGISTRY@1.0] -supersedes: [] -cancels: [] ---- - -# Англоязычные источники стандартов разработки - -## Требования - -### ENGLISH_STANDARD_LINKS_REQUIRE_VERIFICATION - -Англоязычная ссылка добавляется только после подтверждения соответствия -русскому стандарту по идентификатору, заголовку и отличительным признакам. - -### RUSSIAN_STANDARD_SOURCE_REMAINS_PRIMARY - -Каноническая русская ссылка ITS сохраняется первой и остаётся обязательной даже -для страниц, у которых найден проверенный англоязычный источник. - -### STANDARD_SOURCE_REGISTRY_IS_DETERMINISTIC - -Один и тот же отсортированный реестр и корпус Markdown дают байт-в-байт -одинаковый результат повторной генерации. - -### STANDARD_SOURCE_VALIDATION_IS_OFFLINE - -Проверка схемы, уникальности, соответствия страниц и уже записанных ссылок -выполняется без сетевых запросов. - -## Цель - -Дополнить страницы `docs/std/*.md` проверенными ссылками на английские версии -стандартов из 1Ci Knowledge Base, сохранив русскую страницу ИТС как основной -источник. Не создавать ссылку, если отдельное соответствие не подтверждено. - -## Исходные ограничения - -- В репозитории 317 страниц стандартов, и каждая содержит один русский URL ИТС. -- Английский каталог содержит 209 содержательных страниц и не использует номера - `stdNNN` в URL или метаданных. -- Структуры каталогов различаются: некоторые материалы объединены, разделены или - отсутствуют в английской версии. -- Корневая ссылка английского каталога не считается соответствием конкретному - стандарту. -- Английская страница является дополнительной языковой версией, а не заменой - русского источника и не доказательством совпадения редакций. - -## Выбранный подход - -Создать версионируемый реестр соответствий. Для каждой подтверждённой пары он -хранит номер стандарта и канонический английский URL. Скрипт проверяет схему, -уникальность номеров и URL, принадлежность URL ожидаемому разделу 1Ci KB и -синхронизирует управляемую секцию источников в Markdown. - -Реестр не должен содержать предположительных пар. Неоднозначные и отсутствующие -соответствия остаются без английской ссылки; покрытие отражается отдельной -проверкой и отчётом скрипта. - -## Формат страницы - -Для подтверждённого соответствия конец страницы принимает вид: - -```markdown -###### Источники - -- [Русская версия — ИТС](https://its.1c.ru/db/v8std#content:498) -- [English version — 1Ci Knowledge Base](https://kb.1ci.com/1C_Enterprise_Platform/Guides/Developer_Guides/1C_Enterprise_Development_Standards/Code_conventions/Using_1C_Enterprise_language_structures/Event_log/?language=en) -``` - -Для стандарта без подтверждённого соответствия сохраняется существующая секция: - -```markdown -###### Источник - -https://its.1c.ru/db/v8std#content:NNN -``` - -Так читатель не получает ложного впечатления, что общий каталог является -переводом конкретной статьи. - -## Компоненты - -1. Реестр соответствий в `data/` — единственный источник истины для английских - URL. -2. Скрипт синхронизации в `scripts/` — режим проверки без записи и явный режим - обновления страниц. -3. Тесты схемы и рендеринга — валидная пара, отсутствие пары, дубли, неправильный - домен/раздел, идемпотентность. -4. Существующий генератор AI/MCP-артефактов — без отдельной логики: он уже - извлекает все внешние URL из Markdown. - -## Построение реестра - -Сопоставление выполняется по заголовку, структуре и содержимому статьи. Машинный -перевод заголовка может предлагать кандидата, но не является достаточным -доказательством. Для дублей русских заголовков и случаев объединения/разделения -сравнивается содержимое; неподтверждённые случаи не публикуются. - -Первая реализация добавляет все пары, которые удалось подтвердить в текущем -английском каталоге. Полнота ограничена опубликованными материалами 1Ci KB, а не -числом русских стандартов. - -## Ошибки и безопасность - -Проверка завершается ошибкой, если: - -- реестр содержит неизвестный `stdNNN`; -- русская страница не содержит ожидаемую ссылку ИТС на тот же номер; -- номер стандарта или английский URL повторяется; -- URL использует не HTTPS, другой домен или находится вне раздела Development - Standards; -- управляемая секция Markdown расходится с реестром; -- повторная синхронизация меняет уже синхронизированные файлы. - -Сетевой доступ не требуется для обычной сборки и тестов: реестр проверяется -детерминированно. Отдельная проверка доступности внешних страниц может выполняться -при обновлении реестра, но не становится нестабильной частью CI. - -## Проверка результата - -1. Наблюдать падение новых тестов до реализации синхронизации. -2. Запустить тесты скрипта и полный набор тестов проекта. -3. Перегенерировать AI/MCP-артефакты штатным генератором. -4. Проверить отсутствие незаписанных изменений повторным запуском синхронизации - и генератора. -5. Выполнить строгую сборку документации. -6. Проверить `git diff --check` и выборочно открыть несколько страниц с двумя - источниками и страницу без английского соответствия. - -## Вне объёма - -- перевод русских стандартов; -- исправление содержания 1Ci Knowledge Base; -- подстановка корневой ссылки вместо отсутствующей статьи; -- признание английской страницы эквивалентной редакцией без проверки содержания. diff --git a/spec/designs/2026-07-22-unified-diagnostic-chips-design.md b/spec/designs/2026-07-22-unified-diagnostic-chips-design.md deleted file mode 100644 index efbb719..0000000 --- a/spec/designs/2026-07-22-unified-diagnostic-chips-design.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -schema_version: 1 -kind: design -id: unified-diagnostic-chips -scope: product -requirements: - introduces: - - DIAGNOSTIC_IDENTIFIERS_USE_SHARED_CHIPS - - DIAGNOSTIC_CHIPS_ARE_ACCESSIBLE - - GENERATED_DIAGNOSTIC_CHIPS_ARE_IDEMPOTENT - uses: [] - replaces: {} - cancels: [] -decisions: [] -invariants: [] -contracts: [contract:DIAGNOSTIC_CHIP_MARKUP@1.0] -supersedes: [] -cancels: [] ---- - -# Единое оформление ссылок на диагностики - -## Требования - -### DIAGNOSTIC_IDENTIFIERS_USE_SHARED_CHIPS - -Видимые упоминания канонических идентификаторов диагностик используют один -семантический компонент chip во всех реестрах, справке и обратных ссылках. - -### DIAGNOSTIC_CHIPS_ARE_ACCESSIBLE - -Chip остаётся обычной доступной ссылкой с различимыми hover/focus-состояниями, -достаточным контрастом и корректным переносом на узких экранах. - -### GENERATED_DIAGNOSTIC_CHIPS_ARE_IDEMPOTENT - -Повторная генерация не оборачивает уже оформленный идентификатор второй раз и -не создаёт дрейфа Markdown или HTML. - -## Контекст - -В реестре диагностик коды отображаются как компактные кликабельные чипы. На страницах стандартов те же связи выводятся отдельной группой «Проверки» и оформляются иначе. В справочных материалах коды встречаются как обычный встроенный код без ссылки. - -Разные представления одной сущности усложняют чтение и создают несколько независимых способов оформления, которые могут расходиться. - -## Решение - -Все пользовательские упоминания идентификаторов диагностик `acc:…`, `bslls:…` и `v8cs:…` оформляются единым компонентом-ссылкой с классом `.diagnostic-chip`. - -Компонент используется: - -- в реестре диагностик; -- в автоматически создаваемых обратных ссылках на страницах стандартов; -- в примерах на страницах `mcp.md` и `search-help.md`. - -Технические значения внутри поисковых атрибутов и другой невидимой разметки компонентами не являются. - -## Страницы стандартов - -Видимый подзаголовок «Проверки» удаляется. Чипы размещаются непосредственно после текста соответствующего пункта стандарта в отдельном контейнере. - -Контейнер сохраняет смысл группы для вспомогательных технологий с помощью `aria-label="Проверки"`. Для связей со стандартом в целом контейнер остаётся в том месте страницы, где генератор сейчас создаёт общий блок обратных ссылок. - -Генератор остаётся единственным источником этой разметки. Ручное редактирование сгенерированных блоков не требуется. - -## Внешний вид и взаимодействие - -`.diagnostic-chip` повторяет одобренное оформление кодов в реестре: - -- моноширинный шрифт; -- компактные внутренние отступы; -- скруглённый фон; -- перенос контейнера на следующую строку при нехватке ширины; -- различимые состояния наведения и клавиатурного фокуса; -- сохранение читаемости в светлой и тёмной темах. - -Все видимые чипы являются ссылками на каноническую страницу соответствующей диагностики. - -## Реализация - -Локальный класс реестра заменяется или дополняется общим классом `.diagnostic-chip`. Генератор обратных ссылок создаёт контейнер и ссылки с этим классом вместо Markdown-заголовка и зачёркнутых ссылок. Справочные примеры получают явные ссылки с тем же классом. - -Существующие маркеры начала и конца сгенерированных блоков сохраняются, чтобы обновление оставалось идемпотентным и совместимым с проверками целостности. - -## Проверка - -Автоматические тесты должны подтверждать: - -- отсутствие видимого заголовка «Проверки» в сгенерированных блоках; -- наличие общего класса у всех сгенерированных ссылок; -- корректность адресов ссылок для АПК, BSL Language Server и EDT/v8-code-style; -- идемпотентность генератора; -- использование общего класса в реестре и справочных страницах. - -После регенерации выполняются полный набор тестов и строгая сборка документации. Готовая страница проверяется в браузере на широком и узком экранах. diff --git a/spec/designs/2026-08-14-mcp-monitoring-dashboard-design.md b/spec/designs/2026-08-14-mcp-monitoring-dashboard-design.md deleted file mode 100644 index 0d22fcf..0000000 --- a/spec/designs/2026-08-14-mcp-monitoring-dashboard-design.md +++ /dev/null @@ -1,715 +0,0 @@ ---- -schema_version: 1 -kind: design -id: mcp-monitoring-dashboard -scope: product -requirements: - introduces: - - MONITORING_SHOWS_AGENT_FAMILIES - - MONITORING_SHOWS_API_VERSIONS - - MONITORING_SHOWS_MCP_OPERATIONS - - PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA - - OPERATOR_DETAILS_STAY_OUTSIDE_WEB_ROOT - - MONITORING_REMAINS_PUBLIC - - LEGACY_USAGE_EVENTS_REMAIN_READABLE - uses: [] - replaces: {} - cancels: [] -decisions: [adr:PUBLIC_MCP_MONITORING] -invariants: - - invariant:PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA - - invariant:OPERATOR_DATA_STAYS_OUTSIDE_WEB_ROOT -contracts: - - contract:MCP_USAGE_EVENTS@1.0 - - contract:MCP_USAGE_EVENTS@2.0 - - contract:MCP_MONITORING_PROJECTION@1.0 - - contract:MCP_MONITORING_PROJECTION@2.0 -supersedes: [] -cancels: [] ---- - -# Проект аналитики и редизайна мониторинга MCP - -## Требования - -### MONITORING_SHOWS_AGENT_FAMILIES - -Отчёт показывает нормализованные семейства подключавшихся MCP-клиентов, не -публикуя произвольную исходную строку user-agent. - -### MONITORING_SHOWS_API_VERSIONS - -Статистика разделяет использование MCP v2 и MCP v3 и сохраняет явно -обозначенную категорию для событий, версия которых неизвестна. - -### MONITORING_SHOWS_MCP_OPERATIONS - -Отчёт показывает частоты initialize, tools, resources/list и resources/read, а -для tools — безопасную агрегацию по имени метода. - -### PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA - -Публичная проекция не содержит IP, сырых user-agent, request ID, cursor, URI с -пользовательскими параметрами, текстов запросов или малых идентифицируемых срезов. - -### OPERATOR_DETAILS_STAY_OUTSIDE_WEB_ROOT - -Детальные события и операторские отчёты хранятся с ограниченными правами вне -каталога, публикуемого веб-сервером, и доступны оператору через SSH. - -### MONITORING_REMAINS_PUBLIC - -Безопасная агрегированная страница `/monitoring/` остаётся публичной и не -получает обязательную аутентификацию. - -### LEGACY_USAGE_EVENTS_REMAIN_READABLE - -Новый агрегатор продолжает читать существующие legacy access/usage events и -явно маркирует неизвестные измерения вместо отбрасывания истории. - -- Дата: 2026-08-14 -- Основание: - [PUBLIC_MCP_MONITORING](../adr/2026-08-14-public-mcp-monitoring.md) -- Область: MCP v2, MCP v3 и сайт `https://ai.v8std.ru/monitoring/` - -## Контекст - -Текущий мониторинг реализован скриптом -[`scripts/v8std_mcp_monitoring.py`](https://github.com/zeegin/v8std/blob/main/scripts/v8std_mcp_monitoring.py). -Он периодически строит статические `index.html` и `stats.json` за последние 24 -часа из трёх источников: - -- Nginx access log; -- `/var/lib/v8std-mcp/tool-usage.jsonl`; -- состояния `v8std-mcp.service` в systemd. - -Текущий отчёт полезен, но его модель соответствует только MCP v2: - -- MCP-трафиком считается только путь `/mcp`; -- usage-событие обязано иметь поле `tool`; -- чтение страницы распознаётся только как `v8std_get_page`; -- uptime читается только для одного процесса; -- `mcp_requests` фактически означает количество `tools/call`, а не всех MCP - запросов; -- агент определяется по User-Agent отдельного tool call; -- публично показываются последние поисковые запросы; -- публичный `stats.json` содержит те же детальные данные, что и HTML. - -После появления resource-first MCP v3 эта модель станет неполной. Путь -`/v3/mcp` будет ошибочно отнесён к прочим HTTP-запросам, а `resources/list` и -`resources/read` не попадут в статистику использования. Удаление -`v8std_get_page` из v3 также разорвёт текущую эвристику -`search -> get_page`, используемую для генерации search feedback cases. - -Отдельная проблема — безопасность. `noindex` сообщает поисковым роботам о -желательном поведении, но не ограничивает доступ. Поисковый запрос может -содержать имя закрытого проекта, фрагмент внутренней задачи или секрет. Такие -данные нельзя публиковать в HTML или JSON даже при read-only характере MCP. - -## Цели - -Новый мониторинг должен отвечать на следующие вопросы: - -1. Какие семейства MCP-агентов обращаются к серверу. -2. Какую API-версию они используют: v2 или v3. -3. Какие MCP-методы, tools и resource operations вызываются. -4. Какие публичные страницы и диагностики читаются чаще всего. -5. Работают ли оба процесса и насколько свеж их индекс или resource snapshot. -6. Сколько запросов завершилось успешно, с ошибкой или rate limit. -7. Какие данные допустимо публиковать, а какие остаются только на сервере. - -## Не-цели - -- Не считать уникальных пользователей без аутентификации. -- Не идентифицировать пользователя по IP, User-Agent или браузерному - fingerprint. -- Не строить real-time observability и алерты: это область - `LOCAL_OPENMETRICS_EXPOSITION`. -- Не превращать статический сайт в отдельное серверное приложение или базу - данных. -- Не создавать закрытый web-интерфейс: оператор получает расширенный отчёт и - журналы через SSH. -- Не публиковать полный журнал MCP-событий. -- Не использовать `clientInfo` для аутентификации или авторизации. - -## Термины и честные ограничения - -### Событие подключения - -В отчёте «подключение» означает успешно разобранный MCP-запрос `initialize`. -Это не уникальный пользователь и не обязательно долговечная транспортная -сессия. Stateless HTTP-клиент может инициализироваться повторно, а одна система -может обслуживать нескольких пользователей. - -Dashboard показывает число `initialize` и последующие вызовы, но не выводит -метрику «уникальные подключения». Без устойчивого доверенного идентификатора -такая метрика была бы недостоверной. - -### Агент - -Агент — нормализованное семейство клиентской программы, например `claude`, -`codex` или `cursor`. Источники определения в порядке приоритета: - -1. `initialize.params.clientInfo.name` для события `initialize`; -2. безопасная классификация User-Agent для отдельных HTTP-запросов; -3. `unknown`, если классификация невозможна. - -`clientInfo` и User-Agent не заверены и могут быть подделаны. Они используются -только для аналитики. Точное исходное значение не публикуется и не влияет на -доступ или поведение MCP. - -### Версия - -Основная версия на dashboard — прикладная версия MCP v8std, определённая -серверным маршрутом или процессом: - -- `/mcp` — `v2`; -- `/v3/mcp` — `v3`. - -Заявленная версия клиентской программы не заменяет API version. Она не нужна -для цели этого dashboard и не журналируется: точная версия имеет высокую -кардинальность и может использоваться для fingerprinting. - -## Рассмотренные варианты - -### Оставить текущий публичный отчёт и только добавить v3 - -Отклонено. Это сохраняет публикацию поисковых текстов, смешивает usage analytics -с эксплуатационными деталями и не исправляет неоднозначность `mcp_requests`. - -### Полностью закрыть `/monitoring/` - -Отклонено. Безопасные агрегаты полезны публично: они показывают востребованность -сервиса, переход на v3 и популярные открытые материалы. Закрытие всего сайта не -нужно для защиты пользовательского содержимого. - -### Публиковать безопасную проекцию, а операторский отчёт хранить локально - -Принято. Один агрегатор строит два разных артефакта из общей нормализованной -модели событий: - -- публичный агрегированный dashboard; -- расширенный операторский отчёт вне web root. - -Операторский отчёт и сырой журнал никогда не попадают в web root. При -необходимости владелец получает их через SSH или SCP. - -## Решение - -```mermaid -flowchart LR - A["MCP v2 /mcp"] --> E["Versioned usage events"] - B["MCP v3 /v3/mcp"] --> E - C["Nginx access log"] --> F["Normalizer and aggregator"] - D["systemd + health/version"] --> F - E --> F - F --> G["Public projection"] - F --> H["Local operator artifact"] - G --> I["/monitoring/"] - H --> J["SSH / SCP"] - E --> K["Restricted search feedback job"] -``` - -Остаётся один логический сайт мониторинга. Для v3 не создаётся отдельный -dashboard. Обе API-версии сравниваются в одном отчёте. - -Генератор остаётся batch-процессом и атомарно заменяет готовые статические -файлы. Ошибка генерации не влияет на MCP v2 или v3; Nginx продолжает отдавать -последнюю исправную версию отчёта. - -## Контракт событий - -### Общий envelope - -Новая версия внутреннего JSONL-события имеет обязательные поля: - -```json -{ - "schema_version": 2, - "ts": "2026-08-14T00:00:00+00:00", - "api": "v3", - "kind": "mcp_operation", - "method": "resources/read", - "outcome": "success", - "duration_ms": 8, - "agent_family": "claude", - "agent_source": "client_info" -} -``` - -Ограниченные значения: - -| Поле | Допустимые значения | -|---|---| -| `api` | `v2`, `v3` | -| `kind` | `initialize`, `mcp_operation`, `content_usage` | -| `outcome` | `success`, `client_error`, `server_error` | -| `agent_source` | `client_info`, `user_agent`, `unknown` | - -Неизвестный метод или агент нормализуется в `other` либо `unknown`, а не -создаёт новую публичную категорию. - -Один JSON-RPC request создаёт не более одного usage event. `content_usage` — -специализированный вид MCP operation, а не дополнительное событие рядом с -`mcp_operation`; это предотвращает двойной подсчёт totals. - -### Событие initialize - -```json -{ - "schema_version": 2, - "ts": "2026-08-14T00:00:00+00:00", - "api": "v3", - "kind": "initialize", - "method": "initialize", - "outcome": "success", - "agent_family": "claude", - "agent_source": "client_info" -} -``` - -Исходные `clientInfo.name`, `clientInfo.version` и User-Agent классифицируются -в памяти и не записываются в usage event. В событие переходит только -нормализованный `agent_family`. Доступ к MCP не зависит от этих полей. - -### Вызов tool - -```json -{ - "schema_version": 2, - "ts": "2026-08-14T00:01:00+00:00", - "api": "v3", - "kind": "mcp_operation", - "method": "tools/call", - "tool": "v8std_search", - "outcome": "success", - "duration_ms": 16, - "agent_family": "claude", - "agent_source": "user_agent" -} -``` - -`tool` допускает только инструменты соответствующей API-версии. Аргументы tool -в это событие не записываются. - -### Чтение ресурса - -```json -{ - "schema_version": 2, - "ts": "2026-08-14T00:02:00+00:00", - "api": "v3", - "kind": "content_usage", - "method": "resources/read", - "outcome": "success", - "agent_family": "claude", - "resource_type": "standard", - "resource_uri": "v8std://ru/standards/437", - "page_id": "std437", - "title": "Оформление текстов запросов", - "url": "https://v8std.ru/std/437/" -} -``` - -URI, page ID, title и URL допустимы во внутренней аналитике, потому что -описывают уже публичный материал v8std. Markdown страницы не записывается. - -Успешный v2 `v8std_get_page` использует тот же вид события: - -```json -{ - "schema_version": 2, - "ts": "2026-08-14T00:02:00+00:00", - "api": "v2", - "kind": "content_usage", - "method": "tools/call", - "tool": "v8std_get_page", - "outcome": "success", - "agent_family": "claude", - "page_id": "std437", - "url": "https://v8std.ru/std/437/" -} -``` - -Поэтому один вызов увеличивает одновременно `tool_calls` и логический рейтинг -прочитанных страниц, но остаётся одной MCP operation. - -### Получение списка ресурсов - -```json -{ - "schema_version": 2, - "ts": "2026-08-14T00:03:00+00:00", - "api": "v3", - "kind": "mcp_operation", - "method": "resources/list", - "outcome": "success", - "page_size": 100, - "has_cursor": true, - "result_count": 100, - "agent_family": "claude" -} -``` - -Значение cursor и состав выданной страницы не журналируются. - -### Совместимость со старыми логами - -Строка без `schema_version` и `api`, но с полем `tool`, трактуется как legacy -событие v2: - -```json -{"ts":"...","tool":"v8std_get_page","page_id":"std437"} -``` - -Это позволяет строить сквозные окна 7 и 30 дней через момент развёртывания, -не переписывая исторические JSONL-файлы. - -## Разделение usage и search feedback - -Общий поток usage analytics не должен содержать текст поискового запроса. -Поэтому текущая запись `query` разделяется на два назначения: - -1. `usage.jsonl` содержит только метод, outcome, длительность, агент и число - результатов; -2. отдельный `search-feedback.jsonl` содержит нормализованный текст запроса и - публичные ID результатов только для построения weak benchmark cases. - -`search-feedback.jsonl`: - -- имеет права `0640` и не находится внутри web root; -- хранится не более 30 дней; -- удаляет управляющие символы, code fences и очевидные присваивания секретов; -- ограничивает запрос 240 символами; -- никогда не читается генератором сайта; -- обрабатывается локально с ручной проверкой результата перед публикацией - benchmark case. - -Санитизация уменьшает риск, но не считается полной защитой. Основная защита — -отсутствие web-доступа и короткий срок хранения. - -Для v3 переход `search -> resources/read` учитывается наравне с legacy -`search -> v8std_get_page`. Эвристика принимает чтение только если выбранный -page ID присутствовал среди результатов предшествующего поиска того же -нормализованного семейства агента в заданном временном окне. Результат всё равно -не считается доказательством намерения конкретного пользователя. - -## Публичная и локальная операторская проекции - -### Публичный dashboard - -Публичный `https://ai.v8std.ru/monitoring/` содержит: - -- время формирования и выбранное окно; -- общий объём полезных MCP operations; -- число `initialize` без заявления об уникальности; -- долю v2 и v3; -- агрегированное распределение по agent family; -- MCP methods; -- tools/call по имени инструмента; -- resource operations: list, templates/list и read; -- топ публичных страниц и диагностик; -- агрегированные success/error/rate-limit; -- общий статус v2 и v3 без внутренних путей и конфигурации. - -Все значения из ограниченных справочников API version, method, tool и agent -family показываются без порога. Это позволяет видеть даже первое обращение -нового агента и безопасно, потому что исходные client name, version и User-Agent -не публикуются. Неизвестные исходные значения объединяются в `other`, а -невозможность классификации — в `unknown`. - -Публичная проекция не содержит: - -- тексты поисковых запросов и фрагменты кода; -- IP, полный User-Agent, session ID и request ID; -- точные client name и client version; -- cursor; -- необработанные ошибки и stack trace; -- внутренние имена systemd unit, filesystem paths и upstream addresses; -- список посторонних HTTP-сканирований; -- содержимое Markdown. - -### Локальный операторский отчёт - -Локальный отчёт дополнительно показывает: - -- обе systemd-службы, uptime и restart count; -- freshness индекса, snapshot revision и degraded status; -- распределение agent family с отдельным breakdown источника классификации: - `client_info`, `user_agent` и `unknown`; -- точные API version и MCP method counts; -- client/server error classes без пользовательских payload; -- перечень посторонних HTTP paths в нормализованном виде; -- состояние и время последнего успешного запуска генератора. - -Даже локальный отчёт не показывает поисковые тексты, IP, полный User-Agent, -Markdown или stack trace. Для расследования оператор обращается по SSH к -ограниченным исходным логам, а не к публичному dashboard. - -## URL и контроль доступа - -```text -/monitoring/ public HTML -/monitoring/v2/stats.json public sanitized JSON schema v2 -``` - -Текущий `/monitoring/stats.json` временно сохраняется как совместимая -санитизированная проекция старой схемы. Новый HTML его не использует. После -одного релизного цикла и проверки отсутствия потребителей путь можно удалить -отдельным решением. - -Для публичных ответов обязательны: - -```text -X-Robots-Tag: noindex, nofollow, noarchive -X-Content-Type-Options: nosniff -Referrer-Policy: no-referrer -Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self' -``` - -Публичный dashboard может кешироваться не более 60 секунд. Пути с локальным -операторским отчётом в конфигурации Nginx отсутствуют. - -## Хранение и права - -Рекомендуемая структура: - -```text -/var/lib/v8std-mcp/v2-usage.jsonl -/var/lib/v8std-mcp/v3-usage.jsonl -/var/lib/v8std-mcp/search-feedback.jsonl -/var/lib/v8std-mcp/monitoring/operator/index.html -/var/lib/v8std-mcp/monitoring/operator/stats.json -/var/www/ai.v8std.ru-monitoring/ -``` - -- usage и feedback logs: `0640`, каталог: `0750`; -- локальный операторский отчёт: `0640`, каталог: `0750`; -- публичная проекция пишется сначала во временный файл и заменяется через - rename; -- процесс генератора имеет read-only доступ к usage logs и write-доступ только - к публичному каталогу и каталогу локального отчёта; -- Nginx не имеет доступа к `/var/lib/v8std-mcp`; -- usage events без пользовательского содержимого хранятся 90 дней; -- search feedback хранится 30 дней; -- Nginx access log регулируется отдельной политикой и не копируется в JSON - dashboard. - -## Модель данных отчёта - -Новая JSON-схема имеет явную версию и несколько временных окон: - -```json -{ - "schema_version": 2, - "generated_at": "2026-08-14T00:05:00+00:00", - "windows": { - "24h": {}, - "7d": {}, - "30d": {} - }, - "services": { - "v2": {}, - "v3": {} - } -} -``` - -Каждое окно содержит: - -```text -totals -api_versions -agent_families -methods -tools -resource_operations -top_pages -top_diagnostics -outcomes -``` - -Определения счётчиков: - -- `initialize_attempts` — события метода `initialize`; -- `mcp_operations` — разобранные JSON-RPC requests, кроме notifications; -- `useful_operations` — успешные `tools/call` и `resources/read`; -- `tool_calls` — все `tools/call`; -- `resource_reads` — все `resources/read`; -- `discovery_operations` — `tools/list`, `resources/list` и - `resources/templates/list`; -- `rate_limited` — запросы, отклонённые ingress rate limit; -- `error_ratio` — `client_error + server_error` к `mcp_operations`, без - rate-limited запросов. - -Старое имя `mcp_requests` не переопределяется новым смыслом. Оно остаётся только -в legacy JSON как alias прежнего `tool_calls`. - -## Информационная архитектура и редизайн - -### Верхняя область - -- название сервиса; -- время последней сборки; -- переключатель `24 часа / 7 дней / 30 дней`; -- компактные статусы v2 и v3; -- предупреждение, если отчёт устарел более чем на два интервала генерации. - -### KPI - -1. Полезные операции. -2. Initialize attempts. -3. Доля v3. -4. Ошибки. -5. Rate limit. - -### Основные секции - -1. `API versions` — v2 против v3. -2. `Agents` — нормализованные семейства и доля unknown. -3. `MCP methods` — initialize, list, call и read. -4. `Tools` — только вызовы инструментов. -5. `Resources` — list, templates/list, read и типы читаемых ресурсов. -6. `Content` — топ открытых страниц и диагностик, объединяющий v2 get_page и - v3 resources/read. -7. `Reliability` — outcomes и freshness. - -Цвет не является единственным способом передать состояние. Все диаграммы имеют -числовое значение, подпись и табличное представление. Dashboard работает от -320 px, поддерживает keyboard navigation, `prefers-reduced-motion` и системную -цветовую схему. Внешние CDN, fonts, trackers и JavaScript dependencies не -используются. - -## Изменения компонентов - -Текущий файл мониторинга выполняет parsing, aggregation, systemd inspection, -HTML rendering и запись файлов. В ходе доработки ответственность разделяется: - -| Компонент | Ответственность | -|---|---| -| usage event module | schema v2, sanitization и запись событий | -| analytics module | legacy parsing, normalization и aggregation | -| projection module | public/operator field policy | -| dashboard renderer | доступный HTML и локальные assets | -| monitoring CLI | чтение источников, окна, system status и atomic publish | -| search feedback job | единственный потребитель restricted feedback log | - -Конкретные имена файлов утверждаются implementation plan, но public/operator -projection должна быть отдельной проверяемой границей, а не условными блоками -в HTML template. - -## Обработка ошибок - -- Неверная JSONL-строка пропускается и увеличивает internal parse-error count. -- Неизвестная версия schema не интерпретируется как v2. -- Неизвестные agent или method нормализуются в ограниченную категорию. -- Недоступность одного systemd unit не прерывает построение другого. -- Ошибка public projection не должна копировать поля operator artifact в web - root. -- При любой ошибке записи сохраняется предыдущая полностью сформированная - версия файлов. -- Устаревший отчёт явно показывает возраст последней успешной сборки. - -## План реализации - -### Этап 0. Немедленное закрытие утечки - -1. Удалить последние поисковые запросы из публичных HTML и `stats.json`. -2. Добавить security headers. -3. Проверить, что raw JSONL недоступен через Nginx. - -Этап можно развернуть независимо от MCP v3. - -### Этап 1. Versioned event schema - -1. Ввести schema v2 и parser legacy rows. -2. Добавить `api`, `method`, `outcome`, duration и agent family. -3. Разделить usage и search feedback logs. -4. Добавить v3 resource events. - -### Этап 2. Агрегатор v2 + v3 - -1. Поддержать `/mcp` и `/v3/mcp` в access log. -2. Считать 24h, 7d и 30d за один проход по входным данным. -3. Объединить v2 get_page и v3 resources/read по page ID. -4. Получать состояние обеих служб и snapshot metadata. - -### Этап 3. Проекции и редизайн - -1. Ввести public/operator projection tests. -2. Собрать новую JSON schema v2. -3. Реализовать responsive HTML без внешних зависимостей. -4. Добавить accessibility и XSS tests. - -### Этап 4. Безопасный rollout - -1. Shadow-генерация в новый каталог без изменения production route. -2. Сравнение legacy и новых 24h totals на одном наборе логов. -3. Проверка отсутствия Nginx route к operator artifact. -4. Переключение `/monitoring/` на public projection. -5. Проверка скачивания operator artifact через SSH/SCP. -6. Сохранение старого каталога для мгновенного rollback. - -## Тестирование - -Обязательные проверки: - -1. Legacy event без `api` учитывается как v2. -2. События v2 и v3 не смешиваются. -3. `initialize` считается как попытка подключения, но не как unique user. -4. `clientInfo` и User-Agent нормализуются в одинаковый agent family. -5. Неизвестный agent остаётся видимым как `unknown`. -6. `resources/list`, `resources/templates/list` и `resources/read` разделены. -7. Top pages объединяет v2 get_page и v3 resource read. -8. Cursor, query, IP, User-Agent и Markdown отсутствуют в public JSON. -9. Query отсутствует и в operator JSON. -10. Произвольные agent names не создают новые категории и попадают в `other`. -11. Public projection не содержит operator-only полей рекурсивно. -12. HTML escaping блокирует stored XSS из title и agent metadata. -13. Одновременно формируются окна 24h, 7d и 30d. -14. Атомарная публикация сохраняет прежний отчёт при ошибке. -15. Operator HTML и JSON создаются с правами `0640` вне web root. -16. Для operator artifact отсутствует Nginx route. -17. Raw usage и feedback logs не доступны по HTTP. -18. Public dashboard работает без внешних сетевых запросов. -19. Мобильная ширина 320 px не создаёт горизонтальный scroll. -20. Полный набор unit-тестов репозитория проходит. -21. `./scripts/zensical_docs.sh build --strict` проходит. - -## Критерии готовности - -- В одном dashboard видны v2 и v3. -- Видны initialize attempts по нормализованным agent family. -- Видны MCP methods, tools и resource operations. -- Top pages сохраняет непрерывность между get_page и resources/read. -- На публичном route нет пользовательских запросов и эксплуатационных - деталей. -- Operator artifact и raw logs физически отделены от web root и доступны - владельцу через SSH/SCP. -- Старые JSONL-файлы читаются без миграции. -- Rollback сайта не требует rollback MCP v2 или v3. - -## Последствия - -Положительные: - -- статистика отражает resource-first контракт v3; -- переход с v2 на v3 измеряется напрямую; -- публичный сайт остаётся полезным без публикации пользовательского ввода; -- операторская информация не имеет HTTP-поверхности и доступна через SSH; -- схема событий становится пригодной для будущей OpenMetrics-инструментации. - -Отрицательные: - -- потребуется поддерживать публичную проекцию, локальный operator artifact и - совместимый legacy JSON; -- agent family остаётся приблизительной и недоверенной классификацией; -- batch dashboard не заменяет real-time alerts; -- сокращение retention search feedback уменьшает историческое окно слабых - benchmark cases, но это оправданная цена за снижение риска. - -## Источники - -- [Claude: `clientInfo` используется только для telemetry и coarse feature - detection](https://claude.com/docs/connectors/building/testing) -- [MCP specification](https://modelcontextprotocol.io/specification/) diff --git a/spec/designs/2026-08-14-mcp-openmetrics-generation-design.md b/spec/designs/2026-08-14-mcp-openmetrics-generation-design.md deleted file mode 100644 index 6d483b1..0000000 --- a/spec/designs/2026-08-14-mcp-openmetrics-generation-design.md +++ /dev/null @@ -1,541 +0,0 @@ ---- -schema_version: 1 -kind: design -id: mcp-openmetrics-generation -scope: product -requirements: - introduces: - - OPENMETRICS_IS_GENERATED_LOCALLY - - PROMETHEUS_INTEGRATION_IS_DEFERRED - - METRICS_EXCLUDE_HIGH_CARDINALITY_LABELS - - METRICS_ENDPOINTS_USE_LOOPBACK - - METRIC_NAMES_ARE_VERSIONED_CONTRACTS - uses: [] - replaces: {} - cancels: [] -decisions: [adr:LOCAL_OPENMETRICS_EXPOSITION] -invariants: - - invariant:METRICS_ENDPOINTS_ARE_LOOPBACK_ONLY - - invariant:METRICS_LABEL_CARDINALITY_IS_BOUNDED -contracts: [contract:MCP_OPENMETRICS@1.0] -supersedes: [] -cancels: [] ---- - -# Проект генерации OpenMetrics для MCP v2 и v3 - -## Требования - -### OPENMETRICS_IS_GENERATED_LOCALLY - -MCP v2 и v3 формируют валидную OpenMetrics exposition на том же узле, где -возникают counters и gauges, без зависимости от внешнего collector. - -### PROMETHEUS_INTEGRATION_IS_DEFERRED - -Подключение к единому Prometheus, scrape configuration, dashboards и alerts не -входят в текущую реализацию генерации и требуют отдельного решения. - -### METRICS_EXCLUDE_HIGH_CARDINALITY_LABELS - -Labels не содержат IP, user-agent, request ID, cursor, URI, query text и иные -неограниченные или чувствительные значения. - -### METRICS_ENDPOINTS_USE_LOOPBACK - -Exposition endpoint слушает только loopback и не публикуется внешним reverse -proxy или статическим сайтом. - -### METRIC_NAMES_ARE_VERSIONED_CONTRACTS - -Имена метрик, наборы labels и смысл значений являются наблюдаемым -версионированным контрактом, а не внутренней деталью реализации. - -- Дата: 2026-08-14 -- Основание: - [LOCAL_OPENMETRICS_EXPOSITION](../adr/2026-08-14-local-openmetrics-exposition.md) -- Область: эксплуатационная телеметрия MCP v2 и MCP v3 - -## Контекст - -`PUBLIC_MCP_MONITORING` сохраняет статический сайт мониторинга как продуктовую usage analytics: -какие агенты обращаются к MCP, какую API-версию и методы они используют, какие -публичные страницы читают. Этот отчёт строится периодически из журналов и не -предназначен для оперативных графиков или алертов. - -Для эксплуатации нужны time series: - -- частота запросов и ошибок; -- длительность MCP operations; -- состояние и свежесть индекса; -- успешность обновления resource snapshot; -- uptime и состояние процессов. - -OpenMetrics подходит для числовых снимков состояния и накопительных counters. -Он не заменяет события и логи: в OpenMetrics нельзя переносить тексты поисковых -запросов, page ID, resource URI, cursor или другие высококардинальные значения. - -В инфраструктуре планируется единый Prometheus, но его подключение, Grafana и -alert rules не входят в текущий этап. Сначала каждый MCP-процесс должен уметь -корректно генерировать OpenMetrics локально. - -## Решение в одном предложении - -MCP v2 и v3 получают независимые loopback-only endpoints `/metrics`, которые -генерируют OpenMetrics 1.0; подключение этих endpoints к единому Prometheus -откладывается на отдельный инфраструктурный этап. - -## Границы решения - -### Текущий этап: генерация - -Входит: - -- инструментирование процессов v2 и v3; -- OpenMetrics registry каждого процесса; -- endpoint `GET /metrics` на существующем loopback listener; -- content negotiation и корректный OpenMetrics 1.0 response; -- unit и integration tests; -- запрет публичного проксирования `/metrics` через Nginx; -- документация локальной проверки через SSH. - -### Отложенный этап: сбор и использование - -Не входит: - -- изменение конфигурации единого Prometheus; -- сетевой маршрут от Prometheus к MCP host; -- service discovery; -- TLS, mTLS или VPN для удалённого scrape; -- Grafana dashboards; -- recording rules, alerts и SLO; -- retention и remote write; -- объединение с Nginx или host metrics. - -Этот список является явной границей, а не отказом от интеграции. После появления -доступного scrape path ADR дополняется либо создаётся инфраструктурное решение с -адресами, authentication, alert thresholds и rollback. - -## Рассмотренные варианты - -### Использовать только статический сайт `PUBLIC_MCP_MONITORING` - -Отклонено. Batch-отчёт не хранит time series, не даёт надёжно вычислять rate и -latency percentiles и не подходит для alerts. - -### Сразу подключить production Prometheus - -Отложено. Для этого необходимо отдельно проверить сеть, права, TLS, scrape -interval, labels, retention и владельца alerts. Смешивание инструментирования с -инфраструктурным rollout расширит область изменений и усложнит rollback. - -### Генерировать `.prom` файлы для node_exporter textfile collector - -Отклонено для request counters и duration. Периодическая запись файла плохо -соответствует процессным counters и histogram. Textfile collector остаётся -пригодным для batch jobs, но MCP является постоянно работающим HTTP-сервисом. - -### Экспортировать `/metrics` из каждого процесса - -Принято. Это стандартная pull-модель, сохраняющая независимость v2 и v3 и не -требующая промежуточного файла или push gateway. - -## Архитектура - -```mermaid -flowchart LR - A["MCP v2 process
    127.0.0.1:8765"] --> B["/metrics"] - C["MCP v3 process
    127.0.0.1:8766"] --> D["/metrics"] - B -. "future private scrape" .-> E["Unified Prometheus"] - D -. "future private scrape" .-> E - F["Public Nginx"] -- "does not proxy /metrics" --> G["404"] -``` - -На первом этапе endpoints проверяются локально: - -```bash -curl -H 'Accept: application/openmetrics-text; version=1.0.0' \ - http://127.0.0.1:8765/metrics - -curl -H 'Accept: application/openmetrics-text; version=1.0.0' \ - http://127.0.0.1:8766/metrics -``` - -Публичные URL не создаются: - -```text -https://ai.v8std.ru/metrics -> 404 -https://ai.v8std.ru/v3/metrics -> 404 -``` - -Endpoint встроен в существующее ASGI-приложение процесса. Отдельный metrics -HTTP server и дополнительный port на первом этапе не нужны. Processes уже -слушают только loopback, поэтому `/metrics` доступен локальному оператору, но не -публичному ingress. - -## Формат exposition - -Ответ по согласованию OpenMetrics 1.0: - -```text -Content-Type: application/openmetrics-text; version=1.0.0; charset=utf-8 -Cache-Control: no-store -``` - -Тело: - -- UTF-8 без BOM; -- строки заканчиваются LF; -- exposition завершается `# EOF`; -- содержит `HELP`, `TYPE` и `UNIT`, когда unit применим; -- формируется из текущего registry на каждый GET; -- не зависит от предыдущего scrape. - -Для инструментирования используется официальный Python client Prometheus с -отдельным `CollectorRegistry` для v8std MCP. Отдельный registry предотвращает -случайное добавление метрик импортированных библиотек и делает контракт -endpoint проверяемым. Process и Python runtime collectors подключаются к нему -явно. - -## Контракт метрик - -### Информация о процессе - -```text -v8std_mcp_info{api_version="v2",resource_schema="none"} 1 -v8std_mcp_info{api_version="v3",resource_schema="1"} 1 -``` - -Тип: gauge со значением `1`. Один процесс публикует только одну из этих time -series. - -Labels ограничены заранее известными значениями. Build SHA и resource revision -не добавляются в labels, потому что каждая новая версия создавала бы новую time -series. Они остаются в `/version`. - -### MCP operations - -```text -v8std_mcp_operations_total{api_version,method,outcome} -v8std_mcp_operation_duration_seconds{api_version,method} -v8std_mcp_operations_in_progress{api_version,method} -``` - -- `operations_total` — counter; -- `operation_duration_seconds` — histogram; -- `operations_in_progress` — gauge. - -`method` принимает только: - -```text -initialize -ping -tools/list -tools/call -resources/list -resources/templates/list -resources/read -other -``` - -`outcome` принимает только: - -```text -success -client_error -server_error -``` - -Ingress rate limit не включается: отклонённый Nginx запрос не достигает -процесса. Позднее эта метрика должна поступать из ingress exporter, а не -имитироваться приложением. - -Histogram buckets в секундах: - -```text -0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 -``` - -Они покрывают быстрые list/read operations и более долгие search/explain без -создания отдельного histogram для каждого tool. - -### Tool calls - -```text -v8std_mcp_tool_calls_total{api_version,tool,outcome} -``` - -`tool` ограничен опубликованным контрактом соответствующей API-версии. В v3 -отсутствует `v8std_get_page`; неизвестное имя нормализуется в `other`. - -Аргументы инструмента и результаты не являются labels. - -### Resource operations - -```text -v8std_mcp_resource_reads_total{api_version,resource_type,outcome} -v8std_mcp_resource_list_requests_total{api_version,outcome} -v8std_mcp_resource_list_items_total{api_version} -``` - -`resource_type` принимает: - -```text -standard -diagnostic -pattern -article -other -``` - -`resource_list_items_total` увеличивается на число ресурсов, фактически -возвращённых во всех страницах `resources/list`. Размер страницы не label. - -### Каталог и обновления - -```text -v8std_mcp_catalog_rows{api_version} -v8std_mcp_catalog_resources{api_version} -v8std_mcp_catalog_refresh_total{api_version,outcome} -v8std_mcp_catalog_last_success_timestamp_seconds{api_version} -v8std_mcp_catalog_degraded{api_version} -``` - -- rows и resources — gauges; -- refresh total — counter с outcome `success` или `failure`; -- last success — Unix time gauge; -- degraded — gauge `0` или `1`. - -V2 экспортирует количество строк индекса. V3 дополнительно экспортирует число -resource catalog entries. Отсутствующая для версии величина не подменяется -нулём: metric family для неё не публикуется этим процессом. - -Revision не является label. Свежесть вычисляется позднее в PromQL как разность -между временем Prometheus и `last_success_timestamp_seconds`, а не -экспортируется как постоянно меняющийся age gauge. - -### Классификация агентов - -```text -v8std_mcp_agent_operations_total{api_version,agent_family,operation_class} -``` - -`agent_family` использует тот же ограниченный classifier, что -`PUBLIC_MCP_MONITORING`: - -```text -claude -codex -cursor -jetbrains -vscode -opencode -kilo -node -go -python_httpx -java -curl -browser -other -unknown -``` - -`operation_class`: - -```text -initialize -discovery -tool -resource -other -``` - -Exact client name и client version не являются labels. Метрика отражает -заявленное или эвристически классифицированное семейство и не считается -доверенной идентичностью. - -## Запрещённые labels - -Ни одна metric family не содержит: - -- query или нормализованный query; -- tool arguments; -- page ID, title или URL; -- resource URI или cursor; -- diagnostic code; -- resource revision, SHA или build ID; -- IP, User-Agent, MCP session ID или request ID; -- exact client name или client version; -- текст ошибки или exception class, не входящий в закрытый enum. - -Причина — безопасность и кардинальность. Каждая уникальная комбинация labels -создаёт новую time series. Публичные страницы и диагностические коды допустимы в -event analytics `PUBLIC_MCP_MONITORING`, но не в OpenMetrics. - -## Связь с `PUBLIC_MCP_MONITORING` - -Оба решения используют одну нормализацию API version, method, tool, outcome и -agent family, но имеют разные хранилища и назначение: - -| Свойство | `PUBLIC_MCP_MONITORING` | `LOCAL_OPENMETRICS_EXPOSITION` | -|---|---|---| -| Представление | JSONL events и статические отчёты | числовой snapshot | -| История | log files | будущий Prometheus | -| Page ID и URL | допустимы в ограниченных events | запрещены | -| Поисковый текст | только restricted feedback log | запрещён | -| Назначение | usage analytics | operations и alerts | -| Текущий consumer | generator сайта | локальный curl/test | - -Событие не должно сначала записываться в JSONL, а затем перечитываться для -увеличения OpenMetrics counter. Logger и metrics recorder вызываются независимо -из одной точки завершения MCP operation. Ошибка одного observability sink не -останавливает другой и не меняет MCP response. - -## Жизненный цикл counters - -Counters и histograms хранятся в памяти процесса и сбрасываются при restart. -Это нормальное поведение Prometheus instrumentation. Будущий Prometheus -учитывает process restart при вычислении `rate()` и `increase()`. - -OpenMetrics endpoint не читает и не агрегирует старые JSONL. Он показывает -текущий registry процесса и не пытается восстанавливать counters после restart. - -## Безопасность - -- Processes продолжают слушать `127.0.0.1`. -- Nginx не проксирует никакой внешний `/metrics`. -- Endpoint не содержит пользовательского ввода или секретов. -- Response использует `Cache-Control: no-store`. -- Exposition generation не запускает refresh индекса и не выполняет сетевые - запросы. -- Metric HELP text является статической строкой. -- Ошибка генерации metrics возвращает `500` только локальному caller и не влияет - на MCP endpoints. - -При будущем удалённом scrape loopback-ограничение нельзя просто снять на -публичный интерфейс. Требуется отдельный private network path либо authenticated -TLS proxy, рассмотренный вместе с единым Prometheus. - -## Производительность - -- Инкремент counter и observation histogram выполняются in-process. -- Metrics recorder не пишет на диск. -- Labels нормализуются до регистрации time series. -- `/metrics` не захватывает lock resource snapshot на время сериализации. -- Полная генерация exposition должна занимать менее одной секунды. -- Ошибка instrumentation подавляется после диагностического log event и не - меняет результат пользовательского MCP-вызова. - -## Реализация генерации - -### Этап 1. Registry и контракт - -1. Добавить pinned dependency официального Python Prometheus client. -2. Создать отдельный registry и все metric families централизованно. -3. Запретить динамическое создание label values вне enum normalizers. -4. Добавить contract tests имён, типов, labels и buckets. - -### Этап 2. Инструментирование MCP - -1. Измерять method, duration, in-progress и outcome в общей обёртке. -2. Добавить tool counters. -3. Добавить resource counters обеим версиям; три агрегированных ресурса v2 - нормализуются как `other`, а v3 использует типы resource catalog. -4. Использовать общий agent classifier `PUBLIC_MCP_MONITORING`. -5. Проверить, что instrumentation exceptions не меняют MCP response. - -### Этап 3. Каталог и refresh - -1. Устанавливать rows/resources после успешной замены snapshot. -2. Увеличивать refresh counter на каждой попытке. -3. Обновлять last-success timestamp только после полной проверки нового индекса. -4. Сохранять предыдущие gauges при неуспешном refresh и устанавливать degraded. - -### Этап 4. Exposition endpoint - -1. Добавить `GET /metrics` обоим loopback applications. -2. Реализовать OpenMetrics content negotiation и content type. -3. Проверить `# EOF`, LF и отсутствие BOM. -4. Явно проверить production Nginx: публичные metrics URL возвращают `404`. -5. Документировать локальный curl через SSH. - -На этом текущий объём `LOCAL_OPENMETRICS_EXPOSITION` заканчивается. Prometheus configuration не -изменяется. - -## Тестирование - -Обязательные проверки: - -1. V2 и v3 используют разные registries и process counters. -2. API version label определяется сервером, а не входным payload. -3. Каждый method увеличивает только ожидаемый counter. -4. Tool call увеличивает operation и tool counters ровно один раз. -5. Resource read увеличивает operation и resource counters ровно один раз. -6. Failed operation записывает соответствующий ограниченный outcome. -7. Histogram наблюдает duration в секундах и использует утверждённые buckets. -8. In-progress gauge возвращается к нулю после success и exception. -9. Неизвестные method, tool, resource type и agent нормализуются. -10. Запрещённые values не появляются в labels или HELP. -11. Successful refresh меняет rows, resources, counter и last-success. -12. Failed refresh увеличивает failure, включает degraded и сохраняет старый - snapshot. -13. `/metrics` возвращает OpenMetrics 1.0 content type. -14. Exposition завершается `# EOF` и проходит parser OpenMetrics. -15. Exposition не вызывает network fetch или refresh. -16. Instrumentation failure не меняет MCP result. -17. `https://ai.v8std.ru/metrics` возвращает `404`. -18. `https://ai.v8std.ru/v3/metrics` возвращает `404`. -19. Полный набор unit-тестов репозитория проходит. -20. `./scripts/zensical_docs.sh build --strict` проходит. - -## Критерии готовности текущего этапа - -- Оба локальных endpoints выдают валидный OpenMetrics 1.0. -- Метрики различают v2 и v3, methods, tools, resources, outcomes и - нормализованные agent families. -- Метрики каталога отражают успешный и degraded refresh. -- Label cardinality ограничена контрактом. -- Пользовательский ввод отсутствует в exposition. -- Metrics endpoints недоступны через публичный Nginx. -- Ни Prometheus, ни Grafana, ни alerts пока не изменены. - -## Условия начала интеграции с единым Prometheus - -Следующий этап начинается только после определения: - -1. адреса и владельца единого Prometheus; -2. private network path от Prometheus до MCP host; -3. TLS или иной authentication boundary; -4. scrape interval и timeout; -5. external labels и job naming; -6. retention; -7. Grafana folder и владельца dashboard; -8. SLO и alert thresholds; -9. канала доставки alerts; -10. rollback scrape configuration. - -## Последствия - -Положительные: - -- код готов к будущему подключению Prometheus без изменения metric contract; -- v2 и v3 наблюдаются независимо; -- OpenMetrics дополняет, а не дублирует usage analytics; -- public attack surface не увеличивается; -- labels заранее защищены от высокой кардинальности. - -Отрицательные: - -- до подключения Prometheus time series нигде не сохраняются; -- counters сбрасываются при restart и видны только локальному scrape; -- локальный `/metrics` сам по себе не даёт dashboards или alerts; -- metric contract потребуется поддерживать как совместимый API после появления - Prometheus queries и alert rules. - -## Источники - -- [OpenMetrics specification](https://github.com/prometheus/OpenMetrics/blob/main/specification/OpenMetrics.md) -- [Prometheus exposition formats](https://prometheus.io/docs/instrumenting/exposition_formats/) -- [Prometheus instrumentation practices](https://prometheus.io/docs/practices/instrumentation/) -- [Official Prometheus Python client](https://prometheus.github.io/client_python/) diff --git a/spec/designs/2026-08-14-mcp-v3-resource-contract-design.md b/spec/designs/2026-08-14-mcp-v3-resource-contract-design.md deleted file mode 100644 index 8bd5c69..0000000 --- a/spec/designs/2026-08-14-mcp-v3-resource-contract-design.md +++ /dev/null @@ -1,670 +0,0 @@ ---- -schema_version: 1 -kind: design -id: mcp-v3-resource-contract -scope: product -requirements: - introduces: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_VERSIONS_FAIL_INDEPENDENTLY - - MCP_RESOURCE_VERSION_PAGE_READING_USES_RESOURCES - - MCP_RESOURCE_VERSION_HAS_ONE_PRIMARY_PAGE_READER - - MCP_RESOURCE_CATALOG_IS_PAGINATED - - MCP_RESOURCE_LIST_USES_STABLE_SNAPSHOTS - - MCP_RESOURCE_NOTIFICATIONS_ARE_OMITTED - - MCP_RESOURCE_LINKS_RESOLVE_TO_LISTED_RESOURCES - - MCP_RESOURCES_EXCLUDE_SUPPORT_PAGES - - MCP_TEMPLATES_EXCLUDE_LANGUAGE_AND_METHOD_SOURCES - uses: [] - replaces: {} - cancels: [] -decisions: - - adr:MCP_VERSION_ENDPOINT_ISOLATION - - adr:PAGE_READING_VIA_RESOURCES -invariants: - - invariant:MCP_VERSION_ISOLATION - - invariant:MCP_LEGACY_ENDPOINT_STABILITY - - invariant:MCP_RESOURCE_VERSION_PAGE_READING_VIA_RESOURCES - - invariant:MCP_RESOURCE_LINKS_ARE_LISTABLE -contracts: - - contract:MCP_API@2.0 - - contract:MCP_API@3.0 -supersedes: [] -cancels: [] ---- - -# Проект контракта resource-first MCP v3 - -## Требования - -### MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - -Действующий endpoint `/mcp` и его MCP v2 tools сохраняют имена, входы, ответы -и эксплуатационное поведение для существующих клиентов. - -### MCP_VERSIONS_FAIL_INDEPENDENTLY - -Ошибка запуска, каталога или обработки запроса MCP v3 не нарушает доступность -MCP v2; версии развёртываются и откатываются независимо. - -### MCP_RESOURCE_VERSION_PAGE_READING_USES_RESOURCES - -В MCP v3 содержимое страниц стандартов, диагностик и паттернов читается через -`resources/read`, а не через page-reading tool. - -### MCP_RESOURCE_VERSION_HAS_ONE_PRIMARY_PAGE_READER - -У MCP v3 есть один основной механизм чтения страниц — Resources; legacy tool -`v8std_get_page` в контракт v3 не входит. - -### MCP_RESOURCE_CATALOG_IS_PAGINATED - -`resources/list` ограничивает размер страницы и продолжает выдачу opaque -cursor, не заставляя клиента получать весь каталог одним ответом. - -### MCP_RESOURCE_LIST_USES_STABLE_SNAPSHOTS - -Cursor продолжает тот же immutable snapshot каталога; refresh создаёт новый -snapshot для последующего list, не меняя уже начатую пагинацию. - -### MCP_RESOURCE_NOTIFICATIONS_ARE_OMITTED - -Сервер не объявляет resource list-change notifications: стандарты read-only и -публикуются редко, поэтому обновление списка выполняется явным новым list. - -### MCP_RESOURCE_LINKS_RESOLVE_TO_LISTED_RESOURCES - -Каждый resource link из результата tool разрешается в URI, который может быть -получен через текущий каталог Resources и прочитан `resources/read`. - -### MCP_RESOURCES_EXCLUDE_SUPPORT_PAGES - -Служебные страницы `mcp`, `search_help` и `support` не публикуются как -Resources; их возможности остаются tools или документацией поддержки. - -### MCP_TEMPLATES_EXCLUDE_LANGUAGE_AND_METHOD_SOURCES - -Источники `lang` и `metod8dev` не становятся resource templates. Templates -зарезервированы для диагностик и паттернов с параметризуемой идентичностью. - -- Дата: 2026-08-14 -- Основание: - [MCP_VERSION_ENDPOINT_ISOLATION](../adr/2026-08-14-mcp-version-endpoint-isolation.md) - и - [PAGE_READING_VIA_RESOURCES](../adr/2026-08-14-page-reading-via-resources.md) -- Область: публичный и локальный MCP v8std - -## Контекст - -Текущий MCP v2 реализован в -[`scripts/v8std_mcp_server.py`](https://github.com/zeegin/v8std/blob/main/scripts/v8std_mcp_server.py) -и предоставляет: - -- пять инструментов, включая `v8std_get_page`; -- три агрегированных ресурса: `llms.txt`, `llms-full.txt`, `pages.jsonl`; -- endpoint `/mcp`; -- stateless Streamable HTTP; -- периодическое обновление индекса. - -Чтение отдельной страницы сейчас моделируется инструментом, хотя MCP имеет -отдельный примитив Resources. Claude Code поддерживает просмотр ресурсов через -`@`, автоматическую загрузку и операции list/read. Claude.ai и Claude Desktop -также поддерживают текстовые и бинарные MCP Resources. - -Цель новой версии — перенести чтение полного Markdown страницы из -`v8std_get_page` в `resources/read`, сохранить поиск и специализированные -инструменты, а также не нарушить работу существующих клиентов v2. - -## Рассмотренные варианты - -### Изменить `/mcp` на месте - -Отклонено. Удаление `v8std_get_page` сломает существующих клиентов, в том числе -клиентов без поддержки Resources. - -### Согласовывать версию на одном endpoint - -Отклонено. MCP не предоставляет стандартного механизма выбора прикладной версии -контракта. Кеширование схем инструментов клиентами сделало бы поведение -непредсказуемым. - -### Добавить отдельный `/v3/mcp` - -Принято: - -- `/mcp` продолжает обслуживать v2; -- `/v3/mcp` предоставляет resource-first контракт; -- версии разворачиваются и откатываются независимо; -- пользователь переходит на v3 явным изменением URL подключения. - -## Решение - -```mermaid -flowchart LR - A["pages.jsonl и search-vectors.jsonl"] --> B["V8StdIndex"] - B --> C["MCP v2
    /mcp"] - B --> D["Resource policy и URI mapper"] - D --> E["Resource catalog snapshots"] - D --> F["MCP v3 tools adapter"] - E --> G["MCP v3
    /v3/mcp"] - F --> G -``` - -В production v2 и v3 запускаются отдельными процессами или контейнерами из -одного образа. Они читают одни опубликованные AI-артефакты, но не разделяют -runtime-состояние. Ошибка запуска или обновления v3 не должна останавливать v2. - -`stateless_http` означает отсутствие привязки запросов к клиентской сессии, а не -запрет серверного кеша. Snapshot каталога является общим неизменяемым кешем -процесса и не содержит пользовательского состояния. - -Сам ADR помечен `llms.ignore: true`: архитектурное решение не является -пользовательским материалом v8std и не должно менять состав `pages.jsonl`. - -## Контракты версий - -| Возможность | `/mcp` v2 | `/v3/mcp` | -|---|---:|---:| -| `v8std_search` | да | да | -| `v8std_get_page` | да | нет | -| `v8std_get_related` | да | да | -| `v8std_explain_snippet` | да | да | -| `v8std_explain_diagnostics` | да | да | -| Агрегированные ресурсы | да | нет | -| Постраничные ресурсы | нет | да | -| Пагинация `resources/list` | не требуется | да | -| Resource templates | нет | да | -| Уведомления об изменениях | нет | нет | -| Подписки | нет | нет | - -V2 не получает новых типов ресурсов и не меняет схемы инструментов. - -## URI ресурсов - -URI содержит язык корпуса. Первая реализация публикует только `ru`: - -```text -v8std://ru/... -``` - -Префикс резервирует стабильное пространство имён для будущего английского -корпуса без изменения идентичности русских страниц: - -```text -v8std://en/standards/437 -``` - -Страница `lang` не связана с локалью URI: это статья о встроенном языке 1С. - -Публичные HTML и Markdown URL остаются отдельными полями. Они не используются -как MCP resource URI, потому что чтение должно гарантированно проходить через -`resources/read`, а не зависеть от способности клиента самостоятельно загрузить -веб-страницу. - -### Шаблоны - -V3 публикует четыре шаблона: - -```text -v8std://ru/standards/{number} -v8std://ru/diagnostics/{family}/{code} -v8std://ru/patterns/{family} -v8std://ru/patterns/{family}/{slug} -``` - -Примеры конкретных URI: - -```text -v8std://ru/standards/437 -v8std://ru/diagnostics/acc/1245 -v8std://ru/diagnostics/bslls/UsingModalWindows -v8std://ru/diagnostics/v8cs/event-handler-boolean-param -v8std://ru/patterns/gof -v8std://ru/patterns/gof/adapter -``` - -Допустимые семейства диагностик: - -```text -acc -bslls -v8cs -``` - -URI паттерна формируется по публичному пути. Поэтому в URI используются дефисы, -даже если внутренний ID содержит подчёркивания: - -```text -patterns:engineering:rule_of_three --> v8std://ru/patterns/engineering/rule-of-three -``` - -Корневая страница паттернов является конкретным ресурсом: - -```text -v8std://ru/patterns -``` - -### Конкретные ресурсы без шаблонов - -```text -v8std://ru/diagnostics/autoformat -v8std://ru/provenance/third-party-diagnostic-articles -v8std://ru/lang -v8std://ru/metod8dev/3266 -v8std://ru/metod8dev/4105 -``` - -Для `lang` и `metod8dev` шаблоны не регистрируются. - -### Страницы, исключённые из Resources - -Следующие страницы не являются MCP Resources: - -```text -mcp -search_help -support -``` - -Они: - -- отсутствуют в `resources/list`; -- не читаются через `resources/read`; -- не имеют `resource_uri`; -- не возвращаются как `resource_link`. - -Они могут оставаться результатами `v8std_search` с публичным URL. Это отделяет -поисковую видимость сайта от доступности через Resources. - -Универсальный шаблон `v8std://page/{id}` запрещён: он скрывал бы тип страницы и -позволял бы обойти правила исключения. - -## Пагинация `resources/list` - -На момент принятия ADR корпус содержит: - -| Категория | Количество | -|---|---:| -| Стандарты | 318 | -| Диагностики | 1049 | -| Паттерны | 48 | -| Прочие разрешённые страницы | 5 | -| Всего Resources | 1420 | - -Числа являются проверочной базой на момент принятия решения, а не постоянным -ограничением. Реализация рассчитывает состав каталога из актуального индекса и -политики видимости. - -`resources/list` перечисляет конкретные дескрипторы всех разрешённых страниц, -включая страницы, которые читаются через шаблоны. Это обеспечивает поиск через -`@` и не требует отдельного ресурса `v8std://catalog`. - -### Размер страницы - -Сервер возвращает не более 200 ресурсов за один запрос. Стандартный запрос -`resources/list` не имеет параметра `limit`, поэтому размер задаётся сервером и -не конфигурируется клиентом. - -### Snapshot и cursor - -Перед первой страницей сервер: - -1. При необходимости обновляет индекс. -2. Строит неизменяемый список дескрипторов. -3. Сортирует его по `uri`. -4. Рассчитывает revision: - -```text -sha256(resource-schema-version + index-sha256 + visible-resource-policy) -``` - -Cursor является непрозрачным Base64URL-представлением версии формата, revision и -позиции: - -```json -{ - "v": 1, - "revision": "...", - "offset": 200 -} -``` - -Сервер хранит текущий и предыдущий snapshot. Поэтому обновление индекса между -страницами не создаёт пропусков, повторов или смешивания версий каталога. - -Если snapshot вытеснен или процесс перезапущен, старый cursor получает -`-32602 Invalid cursor`. Клиент начинает новый `resources/list` без cursor. - -### Обновление списка - -Push-механизм не реализуется: - -- capability `listChanged` не объявляется; -- capability `subscribe` не объявляется; -- `notifications/resources/list_changed` не отправляется; -- `notifications/resources/updated` не отправляется. - -Новый запрос без cursor видит свежий snapshot. Продолжение по cursor завершает -чтение того snapshot, с которого была начата пагинация. - -## Дескриптор ресурса - -Каждая запись `resources/list` содержит: - -```json -{ - "uri": "v8std://ru/standards/437", - "name": "std437", - "title": "Оформление текстов запросов #std437", - "description": "Краткое описание стандарта", - "mimeType": "text/markdown", - "size": 12345 -} -``` - -`size` равен размеру результата `resources/read` в UTF-8 байтах. - -`lastModified` не публикуется: текущий индекс не содержит достоверной даты -изменения каждой страницы. Время загрузки или генерации индекса не является -датой изменения страницы и поэтому не используется как подмена. - -## `resources/read` - -Чтение возвращает полный Markdown без обрезки: - -```markdown ---- -id: std437 -type: standard -resource_uri: v8std://ru/standards/437 -canonical_url: https://v8std.ru/std/437/ -markdown_url: https://v8std.ru/std/437.md ---- - -# Оформление текстов запросов -... -``` - -Максимальная текущая страница содержит около 26 тысяч символов, поэтому прежний -`body_limit` в v3 не нужен. Ограничение размера проверяется при генерации и в CI; -runtime не должен молча обрезать ресурс. - -Правила чтения: - -- URI разбирается строго по типизированной схеме; -- URL декодируется ровно один раз; -- запрещены `..`, дополнительные `/`, query и fragment; -- aliases и публичные URL в `resources/read` не принимаются; -- неизвестный или исключённый URI возвращает `-32002 Resource not found`; -- сервер никогда не загружает произвольный переданный URL. - -## Результаты инструментов v3 - -Успешный вызов инструмента возвращает: - -1. `structuredContent` с машинными данными, соответствующими опубликованной - `outputSchema` инструмента. -2. Отдельные `ResourceLink` в `content` для доступных страниц. - -Сериализованная копия `structuredContent` в `TextContent` не возвращается. MCP -рекомендует такое дублирование для обратной совместимости, но v3 намеренно -меняет контракт, а совместимая поверхность сохраняется на `/mcp` v2. Клиенты v3 -обязаны поддерживать `structuredContent` и Resources. - -Если результат не содержит доступных ресурсов, `content` является пустым -массивом, а данные остаются в `structuredContent`. `TextContent` используется -только для вызовов с `isError: true`, чтобы модель получила понятное описание -ошибки и способ исправить параметры. - -Вводится инвариант: каждый `resource_link`, возвращённый инструментом, обязательно -присутствует в `resources/list` того же resource schema. - -Пример структурированного результата поиска: - -```json -{ - "schema_version": "v8std.tool-result.v3", - "query": "модальные окна", - "results": [ - { - "id": "std404", - "type": "standard", - "title": "Модальные окна", - "public_url": "https://v8std.ru/std/404/", - "markdown_url": "https://v8std.ru/std/404.md", - "resource_uri": "v8std://ru/standards/404", - "score": 5021.4, - "match_reasons": ["exact_alias"] - } - ] -} -``` - -Следом в `content` передаётся ссылка: - -```json -{ - "type": "resource_link", - "uri": "v8std://ru/standards/404", - "name": "std404", - "title": "Модальные окна", - "mimeType": "text/markdown" -} -``` - -Такая адаптация применяется к: - -- результатам `v8std_search`; -- исходной и связанным страницам `v8std_get_related`; -- стандартам и диагностикам `v8std_explain_snippet`; -- диагностикам и стандартам `v8std_explain_diagnostics`. - -Тело страницы не встраивается в результат инструмента: иначе -`v8std_get_page` фактически вернулся бы под другим именем. - -## Компоненты реализации - -### `ResourceVisibilityPolicy` - -Определяет: - -- какие страницы входят в Resources; -- какие страницы читаются через шаблон; -- какие страницы регистрируются как конкретные ресурсы; -- какие страницы остаются только поисковыми результатами. - -### `ResourceUriMapper` - -Выполняет строгое двустороннее преобразование: - -```text -page -> canonical resource URI -resource URI -> page identity -``` - -Mapper не использует произвольный alias resolution. - -### `ResourceCatalog` - -Отвечает за: - -- построение дескрипторов; -- стабильную сортировку; -- snapshot revision; -- кодирование и проверку cursor; -- хранение двух snapshot; -- формирование полного Markdown ресурса. - -### `V3ToolResultAdapter` - -Добавляет `resource_uri`, создаёт `ResourceLink`, проверяет данные по -`outputSchema` и возвращает `CallToolResult` без дублирующего JSON в -`TextContent`. - -### `V8StdResourceFastMCP` - -Небольшой наследник FastMCP: - -- переопределяет `list_resources` с поддержкой cursor; -- переопределяет `read_resource` с правильными MCP-кодами ошибок; -- возвращает четыре шаблона; -- использует обычную регистрацию FastMCP для четырёх инструментов. - -Это необходимо, потому что закреплённый `mcp==1.27.0` в базовом -`FastMCP.list_resources()` возвращает весь список и не принимает cursor. -Зависимость от этого SDK-контракта закрепляется интеграционными тестами. - -Новая реализация размещается отдельно от v2: - -```text -scripts/v8std_mcp_resources.py -scripts/v8std_mcp_v3_server.py -``` - -Публичные методы `V8StdIndex`, используемые v2, не меняют свои схемы результатов. - -## Развёртывание - -Предлагаемая маршрутизация: - -```text -https://ai.v8std.ru/mcp - -> v8std-mcp-v2:8765 - -https://ai.v8std.ru/v3/mcp - -> v8std-mcp-v3:8766 -``` - -Дополнительные endpoint v3: - -```text -/v3/healthz -/v3/version -``` - -`/v3/version` возвращает как минимум: - -```json -{ - "service": "v8std-mcp", - "api": "v3", - "resource_schema": 1, - "resource_revision": "...", - "row_count": 1423, - "resource_count": 1420 -} -``` - -При неудачном обновлении: - -- если последнего исправного snapshot нет, health возвращает `503`; -- если snapshot есть, health возвращает `200` и `degraded: true`; -- последняя исправная версия продолжает обслуживаться. - -## Совместимость и откат - -- `/mcp` не перенаправляется на v3; -- v2 не получает дату удаления; -- v3 сначала разворачивается как canary; -- откат удаляет только маршрут `/v3/mcp`; -- v2 остаётся рабочим независимо от состояния v3; -- клиенты без Resources продолжают использовать v2. - -## Наблюдаемость - -V3 сохраняет текущую телеметрию инструментов и добавляет агрегированные события: - -- `resource_list` без содержимого страниц; -- `resource_read` с `resource_uri`, page ID и типом; -- revision и API version в health/version. - -Полный Markdown, поисковые фрагменты кода и тела диагностических отчётов в -usage-log не записываются. - -Версионированная схема usage events, безопасная публичная проекция сайта и -локальный операторский отчёт определены в -[проекте мониторинга](2026-08-14-mcp-monitoring-dashboard-design.md). Контракт -локальной генерации OpenMetrics без подключения Prometheus определён в -[проекте OpenMetrics](2026-08-14-mcp-openmetrics-generation-design.md). - -## Тестирование - -Обязательные проверки: - -1. Golden contract v2: те же пять инструментов и три ресурса. -2. V3 не содержит `v8std_get_page`. -3. `resources/templates/list` возвращает ровно четыре шаблона. -4. Все 1420 ресурсов перечисляются ровно один раз. -5. Переход между страницами snapshot не создаёт пропусков и повторов. -6. Обновление индекса не меняет уже начатую пагинацию. -7. Все URI проходят `page -> URI -> page`. -8. `mcp`, `search_help`, `support` отсутствуют в Resources. -9. Неизвестные и поддельные URI возвращают `-32002`. -10. Неверные и устаревшие cursor возвращают `-32602`. -11. Каждый `resource_link` из инструментов присутствует в `resources/list`. -12. Успешные tool results не содержат JSON-копию в `TextContent`. -13. `structuredContent` каждого инструмента соответствует его `outputSchema`. -14. `resources/read` возвращает полный, необрезанный Markdown. -15. Capabilities содержат Resources, но не subscriptions и не `listChanged`. -16. Проверка протокола через MCP Inspector. -17. Проверка `@` и автоматического чтения в Claude Code. -18. Проверка custom connector в Claude.ai или Claude Desktop. -19. Полный набор unit-тестов репозитория. -20. `./scripts/zensical_docs.sh build --strict`. - -Числовые проверки должны вычислять ожидаемый состав из fixture и политики -видимости. Значения 1423/1420 используются как проверка текущего -сгенерированного артефакта, но не зашиваются как вечные константы реализации. - -## План выпуска - -1. Добавить URI mapper, visibility policy и unit-тесты. -2. Добавить snapshot-каталог и пагинацию. -3. Добавить `resources/read` и шаблоны. -4. Добавить адаптацию результатов четырёх инструментов. -5. Поднять локальный v3 независимо от v2. -6. Проверить MCP Inspector, Claude Code и Claude connector. -7. Развернуть закрытый production canary. -8. Опубликовать `/v3/mcp` и документацию подключения. -9. Наблюдать ошибки, чтения ресурсов и использование v2/v3. - -Каждая стадия может быть отменена без изменения `/mcp`. - -## Последствия - -Положительные: - -- чтение документов переносится в предназначенный для этого примитив MCP; -- полный каталог доступен через `@`; -- из v3 исчезает дублирующий `v8std_get_page`; -- страницы получают стабильные типизированные URI; -- v2 сохраняет совместимость; -- v3 можно откатить независимо. - -Отрицательные: - -- требуется два runtime-сервиса; -- пагинация требует отдельного адаптера поверх FastMCP; -- tool-only клиенты не смогут использовать v3 для полного чтения; -- открытый клиент увидит новый состав при следующем обновлении списка, а не по - push-уведомлению. - -## Не входит в решение - -- удаление или срок отключения v2; -- авторизация; -- MCP Apps или UI; -- prompts; -- subscriptions и notifications; -- публикация английского корпуса; -- изменение алгоритма поиска; -- изменение содержания стандартов и диагностик. - -## Источники - -- [MCP Resources specification](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) -- [MCP Tool Resource Links](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#resource-links) -- [Claude Code MCP resources](https://code.claude.com/docs/en/mcp#use-mcp-resources) -- [Claude tool structured content](https://code.claude.com/docs/en/agent-sdk/custom-tools#return-structured-data) -- [Claude custom connector capabilities](https://claude.com/docs/connectors/building#protocol-features) diff --git a/spec/designs/2026-08-14-v8std-architecture-process-design.md b/spec/designs/2026-08-14-v8std-architecture-process-design.md deleted file mode 100644 index e8c5bd5..0000000 --- a/spec/designs/2026-08-14-v8std-architecture-process-design.md +++ /dev/null @@ -1,925 +0,0 @@ ---- -schema_version: 1 -kind: design -id: v8std-architecture-process -scope: process -decisions: [] -invariants: [] -contracts: [] -supersedes: [] -cancels: [] -requirements: - introduces: - - ALL_CHANGES_USE_BRANCHES - - MAIN_ACCEPTS_ONLY_VALIDATED_MERGES - - TRIVIALITY_IS_ASSESSED_NOT_ASSUMED - - ARCHITECTURE_IMPACT_IS_RECHECKED - - REQUIREMENTS_ARE_TRACEABLE - - ARCHITECTURE_DECISIONS_ARE_ATOMIC - - ADR_IDENTITIES_ARE_SEMANTIC - - ARCHITECTURE_INVARIANTS_ARE_SEMANTIC - - OBSERVABLE_BOUNDARIES_ARE_CONTRACTED - - ARCHITECTURE_DOCUMENTS_ARE_IMMUTABLE - - PROJECT_ERRORS_TRIGGER_COMPREHENSIVE_REVIEW - - DESIGN_APPROVAL_PRECEDES_IMPLEMENTATION - - ARCHITECTURE_PROCESS_IS_MACHINE_VALIDATED - - INTERNAL_SPECIFICATIONS_STAY_UNPUBLISHED - - SITE_DEPLOYS_AUTOMATICALLY_FROM_MAIN - - MCP_SERVER_DEPLOYMENT_REQUIRES_EXPLICIT_REQUEST - - EXISTING_SPECIFICATIONS_USE_ONE_MODEL - - SUPERPOWERS_DRIVES_DESIGN_AND_PLANNING - - PRODUCT_ARCHITECTURE_EXCLUDES_DEVELOPMENT_PROCESS - uses: [] - replaces: {} - cancels: [] ---- - -# Архитектурный процесс v8std - -Дата проектирования: 2026-08-14. - -## Результат - -В репозитории вводится единый процесс изменения архитектуры и контрактов: - -- любое изменение файлов выполняется в отдельной Git-ветке; -- `superpowers:brainstorming` управляет проектированием, а repo skill - `v8std-architecture` выполняет архитектурную классификацию; -- требования, ADR, инварианты, контракты, design и plan образуют проверяемый - граф; -- архитектурные документы используют структурированный Markdown без хранимого - поля `status`; -- после локального merge в `main` содержание проектных документов неизменяемо; -- соблюдение процесса обеспечивают `AGENTS.md`, repo skill и машинный - валидатор; -- явно разрешённый push проверенного `main` автоматически публикует сайт; -- обновление MCP-сервера остаётся отдельной ручной операцией и выполняется - только по явному запросу. - -Процесс разработки является обязательной политикой репозитория, но не частью -архитектуры работающего продукта. Git-правила и правила оформления документов -не становятся ADR или архитектурными инвариантами v8std. - -## Контекст - -Текущий каталог `spec/` уже отделён от публикуемого сайта, но смешивает разные -виды документов: - -- `*-design.md` одновременно содержит требования, проект реализации и - нормативные контракты; -- отдельного каталога контрактов и каталога архитектурных инвариантов нет; -- ADR содержат хранимое поле статуса, но не имеют формализованных входных - требований и типизированного влияния на инварианты и контракты; -- последовательные номера ADR создают коллизии между параллельными ветками и - требуют менять уже проверенные имена и ссылки перед merge; -- действующий `AGENTS.md` разрешает прямые коммиты и push в `main`; -- существующие MCP v3, monitoring и OpenMetrics документы описывают принятое - направление, но соответствующая реализация ещё не подтверждена кодом и - исполняемыми проверками. - -Из-за этого нельзя автоматически ответить, почему принято решение, какие -гарантии остаются действующими после его замены, какие контракты изменились и -реализован ли утверждённый design. - -## Область решения - -Решение определяет: - -- вход и завершение архитектурного процесса; -- взаимодействие Superpowers и repo skill; -- причины создания каждого вида документа; -- жизненный цикл требований, ADR, инвариантов и контрактов; -- вычисление текущего состояния из Git и типизированных связей; -- единый branch-first Git-процесс; -- поведение при ошибке предварительной классификации или design; -- ответственность `AGENTS.md`, skill и валидатора; -- одноразовую миграцию существующего `spec/`. - -Решение не реализует MCP v3, новый monitoring dashboard или OpenMetrics. Их -продуктовые design, ADR и будущие implementation plan только переводятся на -единый формат. - -## Требования - -### ALL_CHANGES_USE_BRANCHES - -**Источник:** решение пользователя в ходе event storming. - -Любое изменение файлов, включая тривиальное, документальное и сгенерированное, -выполняется в отдельной Git-ветке. Ветка создаётся до первой записи в рабочее -дерево. Worktree и pull request используются только по явному указанию. - -**Проверка:** правила `AGENTS.md`, pressure-тест repo skill и проверка текущей -ветки перед первой записью. - -### MAIN_ACCEPTS_ONLY_VALIDATED_MERGES - -**Источник:** решение пользователя в ходе event storming. - -Прямые коммиты в `main` и push feature-ветки непосредственно в удалённый -`main` запрещены. После успешных проверок ветка локально сливается в `main`; -способ интеграции не выбирается заново для каждой задачи. - -**Проверка:** финальный impact check, архитектурный валидатор, проектные тесты и -pressure-тест запроса «быстро закоммить прямо в main». - -### TRIVIALITY_IS_ASSESSED_NOT_ASSUMED - -**Источник:** обнаруженная циклическая зависимость в первоначальном процессе. - -Тривиальность является результатом impact check, а не свойством, заданным -пользователем или предполагаемым агентом. Изменение контракта, нарушение ADR или -риск для инварианта всегда делает изменение нетривиальным. - -**Проверка:** pressure-тест изменения, названного тривиальным, но меняющего -контракт. - -### ARCHITECTURE_IMPACT_IS_RECHECKED - -**Источник:** решение пользователя о возврате к проектированию при ошибке -предварительной оценки. - -Impact check выполняется после изучения контекста и повторяется по фактическому -diff перед merge. Если первоначально тривиальная работа стала нетривиальной, -реализация останавливается и переходит в полный архитектурный процесс. - -**Проверка:** pressure-тест эскалации и обязательный pre-merge запуск -валидатора. - -### REQUIREMENTS_ARE_TRACEABLE - -**Источник:** необходимость доказать причины ADR и безопасно отменять решения. - -Архитектурно значимые требования имеют уникальные смысловые коды -`UPPER_SNAKE_CASE` без цифр. Design является источником полной формулировки. -ADR перечисляет входные требования, а design связывает требования с решениями, -контрактами, реализацией и проверками. - -**Проверка:** граф не содержит неопределённых, потерянных или повторно -определённых требований. - -### ARCHITECTURE_DECISIONS_ARE_ATOMIC - -**Источник:** требование сохранять короткие ADR и не связывать независимые -механизмы одним жизненным циклом. - -Один ADR содержит ровно одно архитектурное решение. Он может заменить или -отменить любое количество непосредственно несовместимых ADR. Один старый ADR -может быть разложен на несколько новых атомарных решений, если все они входят в -один design-пакет и попадают в `main` атомарно. - -**Проверка:** review gate атомарности, отсутствие несвязанных замен и циклов -графа. - -### ADR_IDENTITIES_ARE_SEMANTIC - -**Источник:** необходимость независимо разрабатывать ADR в нескольких ветках -без резервирования общего порядкового номера. - -ADR получает устойчивый смысловой идентификатор `UPPER_SNAKE_CASE` без цифр, -например `PAGE_READING_VIA_RESOURCES`. Порядковый номер не является частью -идентичности или междокументной ссылки. Имя файла начинается с даты создания -ADR в design-пакете и обеспечивает хронологическую сортировку: -`YYYY-MM-DD-.md`. Эта дата не меняется при merge. - -**Проверка:** валидатор проверяет формат и уникальность идентификатора в графе -`main + branch`, календарную дату, соответствие slug и ID, типизированные ссылки -и слияние независимых ADR в любом порядке. Repo skill не переименовывает файл в -дату merge. - -### ARCHITECTURE_INVARIANTS_ARE_SEMANTIC - -**Источник:** решение пользователя отказаться от цифровых кодов инвариантов. - -Архитектурный инвариант получает устойчивый смысловой код большими буквами, -например `MCP_VERSION_ISOLATION`. Инвариант вводится только принятым ADR и -описывает долговечное свойство работающей системы, а не правило разработки, -деталь контракта или выбранную библиотеку. - -**Проверка:** схема кода, наличие ADR-основания и автоматической проверки либо -конкретного review gate. - -### OBSERVABLE_BOUNDARIES_ARE_CONTRACTED - -**Источник:** согласованное определение контракта. - -Контракт создаётся для наблюдаемой границы между независимо изменяемыми -производителем и потребителем. Это относится как к публичным MCP-клиентам, так и -к внутренним независимо развиваемым потребителям, например генератору usage -analytics или Prometheus. - -**Проверка:** контракт называет производителя, потребителей, версию, -совместимость и conformance-проверки. - -### ARCHITECTURE_DOCUMENTS_ARE_IMMUTABLE - -**Источник:** решение пользователя замораживать документы при попадании в -`main`. - -После первого merge в `main` содержание design, ADR, invariant, contract и plan -не изменяется и не удаляется. Новое содержание создаётся новым документом, -версией или ревизией со ссылкой на предшественника. - -**Проверка:** валидатор сравнивает существующие документы с merge base и -отклоняет изменение или удаление. - -### PROJECT_ERRORS_TRIGGER_COMPREHENSIVE_REVIEW - -**Источник:** решение пользователя разрешить комплексный пересмотр до merge. - -Если реализация опровергает требование, архитектурную предпосылку, контракт или -инвариант, затронутая работа останавливается. Повторно проверяется весь связанный -граф. Кандидатные документы в ветке исправляются, а для уже принятых в `main` -создаются новые связанные документы, версии или ревизии. После этого пакет снова -проходит пользовательское согласование. - -**Проверка:** pressure-тест проектной ошибки во время реализации. - -### DESIGN_APPROVAL_PRECEDES_IMPLEMENTATION - -**Источник:** согласованный gate Superpowers. - -Нетривиальная реализация не начинается до письменного согласования -design-пакета. Если пользователь запросил только проектирование, процесс -останавливается после merge design-пакета и не выдаёт его за реализацию. - -**Проверка:** наличие design и пользовательского review gate до создания plan; -реализованность вычисляется отдельно. - -### ARCHITECTURE_PROCESS_IS_MACHINE_VALIDATED - -**Источник:** требование не полагаться только на выполнение инструкций агентом. - -Структура документов, граф отношений, трассировка, заморозка и обязательные -проверки валидируются скриптом и тестами репозитория. - -**Проверка:** валидатор входит в обычный CI-набор и имеет положительные и -отрицательные fixture-тесты. - -### INTERNAL_SPECIFICATIONS_STAY_UNPUBLISHED - -**Источник:** прямое ограничение пользователя о недопустимости архитектурных -документов в `docs/`. - -Design, ADR, инварианты, контракты, plan и process specifications находятся в -`spec/` и не включаются в пользовательский корпус сайта или AI-артефакты. - -**Проверка:** тест путей и строгая сборка сайта. - -### SITE_DEPLOYS_AUTOMATICALLY_FROM_MAIN - -**Источник:** уточнение пользователя о двух независимых deployment-операциях. - -Явно разрешённый push проверенного SHA из `main` запускает существующий GitHub -Pages workflow. Отдельное разрешение на публикацию сайта не запрашивается: -разрешение на push включает автоматическую сборку и публикацию сайта. - -**Проверка:** repository test подтверждает trigger `push` для `main` и наличие -шага `actions/deploy-pages` после architecture, test и strict-build gates. - -### MCP_SERVER_DEPLOYMENT_REQUIRES_EXPLICIT_REQUEST - -**Источник:** уточнение пользователя о двух независимых deployment-операциях. - -Локальный merge, push `main` и автоматическая публикация сайта не разрешают -обновление MCP-сервера. MCP deploy выполняется отдельно, вручную, только по -явному запросу и только для проверенного SHA из `main`; после него выполняется -отдельная post-deploy проверка MCP endpoint. - -**Проверка:** `AGENTS.md` и repo skill не выводят разрешение на MCP deploy из -merge, push или site deployment; pressure-сценарий требует отдельного запроса. - -### EXISTING_SPECIFICATIONS_USE_ONE_MODEL - -**Источник:** отказ пользователя от разных процессов и постоянных исключений. - -Все существующие документы `spec/` одноразово мигрируют на новый формат. После -bootstrap-merge постоянный legacy-режим валидатора отсутствует. - -**Проверка:** весь `spec/` проходит одну схему и один графовый валидатор. - -### SUPERPOWERS_DRIVES_DESIGN_AND_PLANNING - -**Источник:** требование явно определить роль Superpowers. - -`superpowers:brainstorming` управляет исследованием и согласованием design. -Repo skill `v8std-architecture` выполняет impact check и определяет необходимый -набор архитектурных документов. Если в объём входит реализация, -`superpowers:writing-plans` создаёт plan после письменного согласования. - -**Проверка:** pressure-сценарии skill и обязательные инструкции `AGENTS.md`. - -### PRODUCT_ARCHITECTURE_EXCLUDES_DEVELOPMENT_PROCESS - -**Источник:** исправление категориальной ошибки, обнаруженной при обсуждении -bootstrap-пакета. - -Git workflow, заморозка документов, применение skill и машинная проверка -являются правилами инженерного процесса. Они не оформляются как продуктовые ADR -или инварианты работающей системы. - -**Проверка:** валидатор различает process specifications и продуктовые -architecture artifacts; миграция не создаёт процессные инварианты. - -## Термины и границы документов - -| Сущность | Назначение | Источник истины | -|---|---|---| -| Требование | Причина и ограничение решения | Design | -| ADR | Одно выбранное архитектурное направление и причины выбора | `spec/adr/` | -| Инвариант | Долговечное обязательное свойство работающей системы | `spec/invariants/` | -| Контракт | Наблюдаемая граница независимых производителя и потребителя | `spec/contracts/` | -| Design | Согласованный снимок требований, вариантов и выбранного проекта | `spec/designs/` | -| Plan | Исполнимый порядок реализации согласованного design | `spec/plans/` | -| Process specification | Нормативный формат и правила инженерного процесса | `spec/process/` | - -Design отвечает на вопрос «что строим и почему именно так». ADR фиксирует только -архитектурный выбор. Контракт фиксирует то, на что рассчитывает потребитель. -Инвариант фиксирует свойство, нарушение которого делает действующую архитектуру -ложной. Plan описывает последовательность реализации и не вводит новых -требований или решений. - -## Участники процесса - -| Участник | Ответственность | -|---|---| -| Пользователь | Определяет цели, согласует требования и design-пакет, отдельно разрешает push и MCP deploy | -| Brainstorming | Управляет исследованием, вариантами, секционным согласованием и письменным design | -| `v8std-architecture` | Классифицирует влияние, строит граф и выбирает необходимые документы | -| Writing Plans | Преобразует согласованный пакет в исполнимый plan | -| Реализатор | Выполняет plan и останавливается при проектной ошибке | -| Валидатор и тесты | Проверяют структуру, граф, контракты, инварианты и фактический diff | -| `main` | Хранит замороженную принятую историю репозитория | -| GitHub Pages | Автоматически публикует сайт после разрешённого push проверенного `main` | -| MCP-сервер | Обновляется вручную только после отдельного явного запроса | - -## Единый поток изменения - -### Вход и предварительная классификация - -```text -ЗАПРОШЕНО ИЗМЕНЕНИЕ -→ SUPERPOWERS:BRAINSTORMING -→ ИЗУЧЕН MAIN И ТЕКУЩАЯ АРХИТЕКТУРА -→ V8STD-ARCHITECTURE ВЫПОЛНИЛ IMPACT CHECK -→ СОЗДАНА ОТДЕЛЬНАЯ ВЕТКА ДО ПЕРВОЙ ЗАПИСИ -``` - -Impact check проверяет наблюдаемое поведение, URI, схемы, ошибки, совместимость, -безопасность, границы процессов и развёртывания, связанные ADR, инварианты, -контракты и проверки. - -Read-only ответ, анализ или диагностика не требуют ветки и design, пока -пользователь не запросил изменение или проектирование. - -### Тривиальный путь - -```text -ВЛИЯНИЕ НА ADR, ИНВАРИАНТЫ И КОНТРАКТЫ НЕ ОБНАРУЖЕНО -→ ДОЛГОВЕЧНЫЙ DESIGN-ПАКЕТ НЕ СОЗДАЁТСЯ -→ ИЗМЕНЕНИЕ РЕАЛИЗОВАНО В ВЕТКЕ -→ IMPACT CHECK ПОВТОРЁН ПО ФАКТИЧЕСКОМУ DIFF -→ ПРОВЕРКИ ПРОШЛИ -→ ВЕТКА ЛОКАЛЬНО СЛИТА В MAIN -``` - -Это проектное сокращение общего Superpowers-процесса применяется только после -доказанного отсутствия архитектурного и контрактного влияния. Git-процесс не -сокращается. - -### Нетривиальный путь - -```text -ВЫДЕЛЕНЫ И СОГЛАСОВАНЫ ТРЕБОВАНИЯ -→ РАССМОТРЕНЫ ДВЕ ИЛИ ТРИ АЛЬТЕРНАТИВЫ -→ СОГЛАСОВАН DESIGN ПО СЕКЦИЯМ -→ ЗАПИСАН НЕОБХОДИМЫЙ DESIGN-ПАКЕТ -→ ПАКЕТ ПИСЬМЕННО ПРОВЕРЕН ПОЛЬЗОВАТЕЛЕМ -├─→ ЗАПРОШЕНО ТОЛЬКО ПРОЕКТИРОВАНИЕ -│ → ПАКЕТ ПРОВЕРЕН И ЛОКАЛЬНО СЛИТ В MAIN -└─→ ЗАПРОШЕНА РЕАЛИЗАЦИЯ - → SUPERPOWERS:WRITING-PLANS - → РЕАЛИЗАЦИЯ - → ФИНАЛЬНЫЙ IMPACT CHECK И ПРОВЕРКИ - → ЛОКАЛЬНЫЙ MERGE В MAIN -``` - -### Ошибка во время реализации - -Расхождение классифицируется как дефект реализации или проектная ошибка. - -Дефект реализации не меняет согласованные требования, ADR, инварианты и -контракты. Исправляются код и при необходимости технические шаги plan. - -Проектная ошибка имеет место, если: - -- требование невозможно или понято неправильно; -- архитектурная предпосылка оказалась ложной; -- безопасная реализация выбранного решения невозможна; -- контракт противоречив или требует другого поведения; -- реализация нарушает инвариант; -- обнаружено незапроектированное внешнее поведение; -- меняются безопасность, совместимость или границы отказа; -- документы design-пакета противоречат друг другу. - -При проектной ошибке: - -```text -ЗАТРОНУТАЯ РЕАЛИЗАЦИЯ ОСТАНОВЛЕНА -→ BRAINSTORMING ВОЗОБНОВЛЁН -→ ВЕСЬ ГРАФ ТРАССИРОВКИ ПРОВЕРЕН -→ КАНДИДАТЫ ИСПРАВЛЕНЫ, ДЛЯ ПРИНЯТЫХ ДОКУМЕНТОВ СОЗДАНЫ ПРЕЕМНИКИ -→ PLAN ИСПРАВЛЕН ИЛИ СОЗДАН ЗАНОВО -→ ПАКЕТ ПОВТОРНО СОГЛАСОВАН -→ РЕАЛИЗАЦИЯ ПРОДОЛЖЕНА -``` - -Существующий diff считается исследовательским свидетельством, а не -автоматически принятым решением. - -Если design был ранее принят как design-only, последующая реализация не -размораживает его. Проектная ошибка оформляется новым design и, по влиянию, -новыми ADR, инвариантами или версиями контрактов. - -## Git и завершение - -1. Контекст и impact check можно читать из `main`. -2. До первой записи создаётся отдельная ветка в основном checkout. -3. Прямые коммиты в `main` и push feature-ветки непосредственно в удалённый - `main` запрещены. -4. Worktree и pull request используются только по явному запросу. -5. После обязательных проверок выполняется автоматический локальный merge. -6. Способ интеграции не запрашивается повторно для каждой задачи. -7. Ветка удаляется после успешного merge. -8. Push локального `main` выполняется только по явному запросу и публикует сайт - автоматически через GitHub Pages workflow. -9. Этот push не разрешает MCP deploy. MCP-сервер обновляется отдельной ручной - операцией только по явному запросу и для точного SHA из `main`. - -Если прямой коммит в `main` уже ошибочно создан, история молча не -переписывается. Нарушение сообщается пользователю; дальнейшее исправление -выполняется в отдельной ветке, а необходимость revert решается отдельно. - -## Причины создания документов - -| Результат анализа | Design | ADR | Инвариант | Контракт | Plan | -|---|---:|---:|---:|---:|---:| -| Тривиальное изменение | нет | нет | нет | нет | нет | -| Нетривиальное изменение | да | по условию | по условию | по условию | если есть реализация | -| Новое устойчивое архитектурное направление | да | да | если появляется обязательное свойство | если меняется граница | если есть реализация | -| Наблюдаемое изменение без смены архитектуры | да | нет | нет | новая версия или ревизия | если есть реализация | -| Внутренняя реализация действующего решения | да | нет | нет | нет | если есть реализация | -| Только проектирование | да | по влиянию | по влиянию | по влиянию | нет | - -ADR требуется, когда меняется ответственность компонентов, изоляция отказов, -публичная или доверенная поверхность, основной протокольный механизм, стратегия -версионирования, модель хранения или наблюдаемости либо ранее принятое -архитектурное направление. - -## Жизненный цикл требований - -Требование вводится в design до выбора ADR. Полная формулировка не дублируется -в ADR, контракте или plan; они используют код. - -- уточнение без изменения обязательства сохраняет код до merge; -- изменение смысла создаёт новый смысловой код; -- новый design явно перечисляет заменённые и отменённые требования; -- старое требование не удаляется из замороженного design; -- отмена проверяет все зависимые ADR, инварианты, контракты и plan; -- изменение вступает в силу только после merge нового пакета в `main`. - -Трекать требуется требования, которые влияют на архитектурную альтернативу, -задают безопасность или совместимость, описывают наблюдаемое поведение, -становятся критерием приёмки либо ограничивают реализацию. - -## Жизненный цикл ADR - -ADR содержит: - -- смысловой идентификатор `UPPER_SNAKE_CASE` без цифр; -- ссылку на design; -- входные архитектурно значимые требования; -- одно решение; -- явное влияние на инварианты; -- явное влияние на контракты; -- отклонённые альтернативы; -- `supersedes` и `cancels`, если применимо. - -Имя файла состоит из даты создания ADR в design-пакете и kebab-case-проекции -идентификатора: -`PAGE_READING_VIA_RESOURCES` хранится в -`spec/adr/2026-08-14-page-reading-via-resources.md`. Дата фиксируется при первом -создании ADR в feature branch, не заменяется датой merge и не дублируется во -front matter. Ссылки типизированы и используют идентичность, а не путь: -`adr:PAGE_READING_VIA_RESOURCES`. - -Каждая ветка выбирает идентификаторы независимо. Валидатор сверяет их с -актуальным `main` непосредственно перед merge. Совпадение означает возможное -дублирование архитектурного решения: одинаковые решения объединяются, а -разные получают разные смысловые коды. Кандидат в ветке можно переименовать; -принятый ADR переименовывать нельзя. - -`aliases` не является механизмом разрешения коллизий. У новых ADR список -aliases пуст; числовые aliases разрешены только одноразовой bootstrap-миграции -для поиска исторических упоминаний `ADR-0001`–`ADR-0004`. - -Новый ADR обязателен и для полной отмены архитектурного решения. Он может -заменить или отменить несколько непосредственно связанных ADR. Несколько новых -ADR могут разложить один старый ADR. Все затронутые решения должны входить в -один design-пакет, чтобы `main` не содержал промежуточного состояния. - -Хранимого поля `status` нет. Эффективное состояние вычисляется по присутствию в -Git и входящим отношениям. - -Порядковый номер ADR не хранится. Каталог и индекс сортируются -лексикографически по имени файла: сначала по дате создания, а внутри одной даты -по смысловому slug. Этот порядок предназначен для навигации и не используется -как идентичность или ссылка. - -## Жизненный цикл инварианта - -Инвариант вводится, если одновременно выполняются условия: - -- свойство следует из ADR; -- оно должно сохраняться во всех последующих реализациях; -- нарушение делает архитектурное решение ложным; -- свойство не является деталью одной версии контракта; -- соблюдение проверяется тестом, валидатором или конкретным review gate. - -Новый ADR явно сохраняет, заменяет или отменяет каждый затронутый инвариант. -Молчаливое исчезновение запрещено. - -Инвариант заменяется при несовместимом изменении требования, направления, -границы ответственности, безопасности или отказа. Отмена допустима, когда -отменено породившее требование, устранена защищаемая граница либо новое решение -явно не сохраняет свойство. Неудобство реализации, сложность теста и срок не -являются основаниями изменения. - -## Жизненный цикл контракта - -Контракт существует на границе независимо изменяемых производителя и -потребителя. Приватные функции и непрозрачные детали реализации контрактами не -являются. - -- любое наблюдаемое изменение увеличивает `version`; -- `compatibility` указывает `backward-compatible` или `breaking`; -- чистое редакционное уточнение сохраняет `version`, но увеличивает `revision`; -- старая версия сохраняется до завершения миграции; -- breaking change не требует ADR автоматически, но всегда проходит impact - check; -- retirement запрещён при действующих требованиях, инвариантах или - потребителях и требует завершённого migration plan. - -Ссылка на конкретный контракт имеет форму `CONTRACT_CODE@version.revision`. - -## Структурированный Markdown и вычисляемые состояния - -Архитектурные документы содержат YAML front matter без поля `status`. -Нормативная схема хранится в версионированной process specification, а skill и -валидатор используют её как единственный источник формата. - -Минимальная структура: - -```text -spec/ - designs/ - adr/ - invariants/ - contracts/ - plans/ - process/ -``` - -Вычисляемые состояния: - -| Состояние | Основание | -|---|---| -| `CANDIDATE` | Новый документ существует только в feature branch | -| `ACCEPTED` | Документ попал в `main` и не имеет завершающего входящего отношения | -| `SUPERSEDED` | Новый принятый документ содержит `supersedes` | -| `CANCELLED` | Новый принятый ADR или design содержит `cancels` | -| `DEPRECATED` | Новая версия контракта содержит `deprecates` | -| `RETIRED` | Новый принятый ADR явно отменяет инвариант после проверки требований | -| `IMPLEMENTED` | Завершённый plan в `main` перечисляет реализованные артефакты | -| `SITE_DEPLOYED` | Проверенный SHA опубликован GitHub Pages workflow после push `main` | -| `MCP_DEPLOYED` | Точный SHA вручную развёрнут на MCP-сервере и подтверждён post-deploy проверкой | - -`SITE_DEPLOYED` и `MCP_DEPLOYED` не хранятся как архитектурные статусы -репозитория. - -После первого появления документа в `main` его содержимое и путь неизменяемы. -Новый документ содержит прямую ссылку на предшественника. Обратная ссылка в -старый документ не добавляется. - -## Обязательная структура ADR - -```markdown ---- -kind: adr -id: PAGE_READING_VIA_RESOURCES -aliases: [] ---- - -# ADR: Краткое название одного решения - -## Входные требования - -- `ARCHITECTURALLY_SIGNIFICANT_REQUIREMENT` - -## Решение - -Краткая нормативная формулировка. - -## Влияние на инварианты - -- `EXISTING_INVARIANT` — сохраняется; -- `NEW_INVARIANT` — вводится. - -## Влияние на контракты - -- `EXISTING_CONTRACT` — не затрагивается; -- `NEW_CONTRACT` — вводится. - -## Отклонённые альтернативы - -- альтернативное направление и причина отказа. -``` - -Разделы влияния обязательны даже при пустом результате. Детальные требования, -JSON Schema, URI, metric labels и план реализации в ADR не копируются. - -## Условия отклонения валидатором - -### Замороженные документы - -- существующий в `main` design, ADR, invariant, contract или plan изменён, - перемещён либо удалён; -- наблюдаемое изменение внесено в существующую версию контракта; -- смысл существующего требования или инварианта переписан. - -### Структура и граф - -- отсутствует обязательный front matter; -- основной смысловой код, для которого схема требует `UPPER_SNAKE_CASE`, - содержит цифры или имеет другой формат; -- имя ADR не соответствует `YYYY-MM-DD-.md`, содержит - несуществующую календарную дату либо slug не является точной kebab-case- - проекцией ADR ID; -- дата ADR продублирована во front matter; -- ADR использует порядковый номер как идентичность, имя нового файла или - текущую междокументную ссылку; -- новый ADR объявляет alias либо цифровой alias используется не как - историческая ссылка на bootstrap-ADR; -- повторно определена существующая идентичность или версия; -- ADR-идентификатор занят в актуальном `main`, включая alias; -- междокументная ссылка не указывает тип сущности; -- ссылка не разрешается либо имеет недопустимый тип; -- граф замены содержит цикл; -- старый ADR одновременно отменён и заменён; -- составная замена распределена по разным design-пакетам; -- ADR не содержит входных требований или явного влияния на инварианты и - контракты; -- замена или отмена не определяет судьбу всех зависимых артефактов. - -### Трассировка - -- требование не связано с решением, контрактом, реализацией, проверкой либо - явным результатом `архитектурное влияние отсутствует`; -- ADR использует неопределённое или отменённое требование; -- новый ADR молча отбрасывает входное требование заменяемого решения; -- действующий инвариант остался только с отменённым основанием; -- контракт зависит от отменённого и не заменённого инварианта. - -### Инварианты, контракты и реализация - -- инвариант не введён ADR или не имеет исполнимой проверки/review gate; -- контракт не называет производителя, потребителей, совместимость и - conformance-проверки; -- контракт retired до завершения migration plan; -- связанный contract или invariant test не проходит; -- фактический diff меняет схему, URI, ошибки или наблюдаемое поведение без - новой версии контракта; -- при реализации design отсутствует plan; -- plan имеет незавершённые обязательные шаги; -- не прошли валидатор и обязательные проверки проекта. - -Историческое упоминание отменённого ADR допустимо в замороженном design, -контексте, `supersedes`, `cancels` и отклонённых альтернативах. Использовать его -как текущее основание нового решения, инварианта или контракта запрещено. - -## Три уровня исполнения - -### `AGENTS.md` - -Содержит только безусловные правила: - -- всегда работать в отдельной ветке; -- не коммитить в `main` и не push feature-ветку непосредственно в удалённый - `main`; -- не использовать worktree или PR без явного указания; -- применять brainstorming и `v8std-architecture`; -- хранить внутренние спецификации только в `spec/`; -- не реализовывать нетривиальное изменение до согласования design-пакета; -- не изменять замороженные документы; -- не выполнять merge без финального impact check, валидатора и тестов; -- не выполнять push без явного запроса; разрешённый push автоматически - публикует сайт; -- не выполнять MCP deploy без отдельного явного запроса. - -Полный процесс и схемы в `AGENTS.md` не дублируются. - -### Repo skill - -Располагается в: - -```text -.agents/skills/v8std-architecture/ - SKILL.md - references/ -``` - -Skill выполняет impact check, классифицирует изменение, управляет созданием -графа, возвращает работу в brainstorming при проектной ошибке и запускает -предписанные проверки. Нормативную схему он читает из `spec/process/`, а не -копирует. - -### Валидатор - -Валидатор разбирает structured Markdown, строит граф для `main + branch diff`, -вычисляет состояния, проверяет заморозку и запускает либо проверяет объявленные -fitness checks. Совпадение изменённого пути с `governs` является сигналом -проверить влияние, но не автоматическим доказательством изменения контракта. - -## Одноразовая миграция - -Первый implementation plan нового процесса выполняет полную миграцию без -постоянного legacy-режима: - -1. Создать инвентаризацию всех текущих `spec`-документов. -2. Переместить design и plan в отдельные каталоги. -3. Добавить front matter без `status`. -4. Переименовать числовые ADR в семантические и сохранить `ADR-0001`–`ADR-0004` - только как исторические aliases. -5. Выделить и закодировать требования. -6. Вынести продуктовые контракты из больших design-документов. -7. Извлечь только продуктовые инварианты из действующих ADR. -8. Добавить типизированные связи и process specification схемы. -9. Классифицировать design как принятые либо реализованные на основании plan, - кода и тестов. -10. Добавить skill, `AGENTS.md`, валидатор и тесты. -11. Проверить весь `spec/` одной схемой и локально слить ветку в `main`. - -Миграция корпуса является одной транзакцией implementation plan. Все -существующие design, plan и ADR сначала перемещаются и переводятся на новую -схему; только после этого запускается обязательная проверка всего `spec/` и -создаётся единый commit миграции. Нельзя требовать full-corpus gate после -перевода только части legacy-документов: такое промежуточное состояние по -определению ещё не удовлетворяет новой схеме. - -Парсер при этом не получает bootstrap-флаг, allowlist или правило игнорирования -unstructured-файлов. До завершения транзакции допустимы только focused-проверки -уже преобразованных документов и продуктовые тесты. Финальный gate транзакции -обязан обнаруживать любой оставшийся unstructured-файл в каталогах схемы и -любой root-level `spec/*.md`, кроме `spec/README.md`. - -Точное преобразование существующих ADR: - -| Исторический alias | Новый ID | Новый файл | -|---|---|---| -| `ADR-0001` | `MCP_VERSION_ENDPOINT_ISOLATION` | `2026-08-14-mcp-version-endpoint-isolation.md` | -| `ADR-0002` | `PUBLIC_MCP_MONITORING` | `2026-08-14-public-mcp-monitoring.md` | -| `ADR-0003` | `LOCAL_OPENMETRICS_EXPOSITION` | `2026-08-14-local-openmetrics-exposition.md` | -| `ADR-0004` | `PAGE_READING_VIA_RESOURCES` | `2026-08-14-page-reading-via-resources.md` | - -Миграция является одноразовым исключением, разрешающим переписать документы, -созданные до принятия правила заморозки. После bootstrap-merge исключение -исчезает. - -## Тестирование - -### Валидатор - -Fixture-тесты покрывают: - -- корректный граф; -- независимые семантические ADR, сливаемые в любом порядке; -- коллизию одинакового ADR ID между `main` и веткой; -- запрет порядкового номера и разрешённые bootstrap-aliases; -- валидную дату и соответствие filename slug семантическому ADR ID; -- сортировку нескольких ADR одного дня; -- замену одного ADR несколькими и нескольких ADR одним; -- циклы и недопустимые отношения; -- историческое и текущее упоминание отменённого ADR; -- сохранение, замену и отмену инварианта; -- совместимую и breaking версию контракта; -- преждевременный retirement; -- изменение замороженного документа; -- потерянное требование; -- accepted design без реализации и implemented design с завершённым plan. - -### Skill - -Skill создаётся через `superpowers:writing-skills` по RED–GREEN–REFACTOR. -Pressure-сценарии сначала выполняются без нового skill, затем с ним: - -- пользователь называет контрактное изменение тривиальным; -- пользователь просит быстро изменить и закоммитить непосредственно в `main`; -- агент предлагает заменить дату создания ADR датой merge; -- реализация опровергает design; -- новый ADR молча теряет старый инвариант; -- агент переписывает существующую версию контракта; -- design-only работу называют реализованной; -- merge предлагается при незавершённом plan. - -Ожидаемое поведение Git-сценария всегда одинаково: создать ветку, проверить и -локально слить. Тест не вводит допустимого исключения для тривиальной работы. - -### Репозиторий - -- `AGENTS.md` содержит обязательные правила; -- repo skill проходит штатную валидацию skill; -- весь `spec/` проходит архитектурный валидатор; -- contract и invariant checks исполняются; -- внутренние документы не входят в публичный сайт и AI-артефакты; -- полный набор тестов и строгая сборка документации проходят. - -## Рассмотренные способы хранения графа - -### Структурированный Markdown - -Принято. Markdown остаётся единственным человекочитаемым источником истины, а -небольшой front matter позволяет валидатору строить граф. - -### Центральный YAML-реестр - -Отклонено. Он создаёт второй источник истины либо требует генерации всех -документов, связывает независимые изменения одним файлом и усложняет review. - -### Только неструктурированный Markdown - -Отклонено. Нельзя надёжно проверить отменённые ADR, полноту трассировки, -заморозку и допустимые отношения. - -## Трассировка требований - -| Требование | Реализация процесса | Проверка | -|---|---|---| -| `ALL_CHANGES_USE_BRANCHES` | `AGENTS.md`, repo skill | pressure-тест ветки | -| `MAIN_ACCEPTS_ONLY_VALIDATED_MERGES` | единый local-merge gate | validator + project tests | -| `TRIVIALITY_IS_ASSESSED_NOT_ASSUMED` | предварительный impact check | contract-change pressure test | -| `ARCHITECTURE_IMPACT_IS_RECHECKED` | финальный diff check | escalation fixture | -| `REQUIREMENTS_ARE_TRACEABLE` | semantic requirement graph | graph validator | -| `ARCHITECTURE_DECISIONS_ARE_ATOMIC` | ADR schema и review gate | replacement fixtures | -| `ADR_IDENTITIES_ARE_SEMANTIC` | dated filename, semantic ADR ID и typed references | filename and parallel-branch fixtures | -| `ARCHITECTURE_INVARIANTS_ARE_SEMANTIC` | invariant schema | naming and source checks | -| `OBSERVABLE_BOUNDARIES_ARE_CONTRACTED` | versioned contracts | producer/consumer/conformance checks | -| `ARCHITECTURE_DOCUMENTS_ARE_IMMUTABLE` | merge-base comparison | frozen-document fixture | -| `PROJECT_ERRORS_TRIGGER_COMPREHENSIVE_REVIEW` | brainstorming loop | implementation-error pressure test | -| `DESIGN_APPROVAL_PRECEDES_IMPLEMENTATION` | written review gate | missing-plan and design-only fixtures | -| `ARCHITECTURE_PROCESS_IS_MACHINE_VALIDATED` | validator and CI | full validator suite | -| `INTERNAL_SPECIFICATIONS_STAY_UNPUBLISHED` | `spec/` scope | artifact and strict-build tests | -| `SITE_DEPLOYS_AUTOMATICALLY_FROM_MAIN` | push-triggered GitHub Pages workflow | workflow trigger and ordering test | -| `MCP_SERVER_DEPLOYMENT_REQUIRES_EXPLICIT_REQUEST` | skill authority boundary | MCP deploy pressure test | -| `EXISTING_SPECIFICATIONS_USE_ONE_MODEL` | bootstrap migration | no-legacy full-corpus check | -| `SUPERPOWERS_DRIVES_DESIGN_AND_PLANNING` | required skill sequence | skill scenarios | -| `PRODUCT_ARCHITECTURE_EXCLUDES_DEVELOPMENT_PROCESS` | separate process schema | artifact-kind validation | - -## Обработка сбоев - -- Если impact check не может доказать тривиальность, применяется нетривиальный - путь. -- Если валидатор не запускается или схема неоднозначна, merge запрещён. -- Если проверка инварианта только ручная, review gate называет проверяемое - условие и владельца решения; формулировка «проверить вручную» недостаточна. -- Если migration обнаруживает противоречивые действующие документы, они не - нормализуются молча: противоречие выносится пользователю до merge. -- Если site deployment не прошёл, push и SHA сохраняются, workflow исследуется - как публикационный сбой, а архитектурные документы не переписываются. -- Если MCP deploy не прошёл, репозиторий остаётся желаемым состоянием; - выполняется предусмотренный rollback MCP-сервера, а документы не - переписываются под аварию. - -## Последствия - -Положительные: - -- причины решений и внешние гарантии разделены; -- замена ADR не теряет требования, инварианты и контракты; -- design-only работа не выдаётся за реализованную; -- тривиальная и нетривиальная работа используют один Git-процесс; -- архитектурный граф можно проверять автоматически; -- внутренние проектные документы не попадают на сайт. - -Отрицательные: - -- первая миграция затрагивает весь существующий `spec/`; -- любое последующее исправление замороженного документа требует нового - документа или ревизии; -- branch-first процесс добавляет ветку даже для опечатки; -- валидатор и skill становятся поддерживаемыми компонентами инженерного - процесса. - -## Источники - -- [OpenAI Docs: Build skills](https://learn.chatgpt.com/docs/build-skills) -- [OpenAI Docs: AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md) diff --git a/spec/designs/2026-09-03-mcp-100k-agent-capacity-design.md b/spec/designs/2026-09-03-mcp-100k-agent-capacity-design.md deleted file mode 100644 index a668252..0000000 --- a/spec/designs/2026-09-03-mcp-100k-agent-capacity-design.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -schema_version: 1 -kind: design -id: mcp-100k-agent-capacity -scope: product -requirements: - introduces: - - MCP_POST_ONLY_AGENT_TRANSPORT - - MCP_EDGE_CONNECTION_CAPACITY - - MCP_AGENT_REQUESTS_SCALE_HORIZONTALLY - - MCP_OVERLOAD_RETURNS_RETRYABLE_STATUS - - MCP_WORKER_DRAIN_IS_BOUNDED - uses: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_VERSIONS_FAIL_INDEPENDENTLY - replaces: {} - cancels: [] -decisions: - - adr:MCP_POST_ONLY_EDGE_DRAIN -invariants: - - invariant:MCP_POST_ONLY_TRANSPORT - - invariant:MCP_EDGE_DRAIN_IS_BOUNDED - - invariant:MCP_OVERLOAD_IS_RETRYABLE -contracts: - - contract:MCP_API@2.1 -supersedes: [] -cancels: [] ---- - -# Capacity design for 100,000 coding-agent connections - -## Scope and capacity envelope - -The target is 100,000 simultaneously connected coding agents, not an -unstated 100,000 requests per second. The initial measurable envelope is: - -- 100,000 client connections at the edge; -- 10,000 MCP POST requests per second at peak; -- 20,000 requests per second for a 30-second burst; -- p95 ordinary tool response below 500 ms and p99 below 1 second; -- one edge node may fail without losing service; -- reload and drain do not produce nginx 500 responses or leave workers alive - indefinitely. - -The POST-RPS and latency figures are sizing assumptions. The implementation -must replace them with measured per-replica capacity before production capacity -is claimed. - -## Требования - -### MCP_POST_ONLY_AGENT_TRANSPORT - -The stateless v2 endpoint serves coding-agent JSON-RPC through POST. An -unsolicited event-stream GET returns 405, so idle agents do not hold an MCP -SSE stream. - -### MCP_EDGE_CONNECTION_CAPACITY - -The deployment has enough independent edge capacity for 100,000 simultaneous -client connections with one edge node unavailable. - -### MCP_AGENT_REQUESTS_SCALE_HORIZONTALLY - -MCP request processing is stateless and can scale by adding replicas without -session affinity or shared client state. - -### MCP_OVERLOAD_RETURNS_RETRYABLE_STATUS - -Admission and backend capacity failures are exposed as retryable 429 or 503 -responses with retry guidance, not as generic nginx 500 responses. - -### MCP_WORKER_DRAIN_IS_BOUNDED - -Edge worker shutdown and service termination have explicit finite deadlines so -long-lived connections cannot retain obsolete workers indefinitely. - -## Decisions - -### POST-only MCP request path - -The service is read-only and uses `stateless_http=True`, `json_response=True`. -It does not send unsolicited server-to-client messages. Therefore an -unsolicited Streamable HTTP GET SSE stream has no product value and is the -wrong resource boundary for a large agent fleet. - -`POST /mcp` remains the canonical path. `GET /mcp` with -`text/event-stream` is answered with `405 Method Not Allowed` and -`Allow: POST, HEAD`. Plain browser GET and HEAD self-documentation remain -available. This is compatible with the Streamable HTTP transport, which allows -the server to return 405 when it does not offer an SSE stream. - -### Edge connection plane - -The edge terminates TLS and owns client connection capacity. Four or more edge -nodes provide N+1 capacity; each node is sized for 40,000 client connections, -leaving 120,000 capacity after one node fails. The edge uses event-driven -workers, a 65,536 connection limit per worker, a 131,072 file-descriptor -limit, 30-second client keepalive, and a 30-second worker shutdown timeout. - -The edge uses a bounded upstream keepalive pool. It does not maintain one -upstream socket for every idle agent. `proxy_read_timeout` is 35 seconds for -request-scoped POST operations; it is not used as the primary control for SSE. -Each edge server admits at most 40,000 MCP connections; four nodes therefore -retain 120,000 admission capacity after one node fails. - -### Stateless request plane - -MCP replicas share no client session state and require no session affinity. -Replica count is calculated from measured POST capacity: - -```text -ceil(peak_post_rps / measured_replica_rps * 1.5) -``` - -The index is an immutable, versioned snapshot loaded by each replica. Large -static AI artifacts are served from CDN/object storage where possible. Search -and tool calls remain bounded, read-only operations. - -### Overload and identity - -The edge returns 429 for request-rate admission failures; connection admission -and unavailable backend capacity return 503 with `Retry-After`. Upstream 502/504 -failures are normalized to 503 at this boundary. Neither overload condition is -represented as an nginx-generated 500. Coding agents must use exponential -backoff with jitter for retryable failures. - -The current public endpoint has no trusted identity. User-Agent, clientInfo, -and IP are not authentication. IP limits are only a coarse emergency guard; -tenant/API-key quotas are required for guaranteed customer-specific capacity, -because multiple agents commonly share a NAT address. - -## Agent lifecycle - -Agents initialize once per process and then issue short POST requests for -tools. A client may attempt the optional GET stream, but receives a definitive -405 and must continue with POST. There is no automatic GET reconnect loop to -recreate the exhausted connection pool. A deploy drain closes only existing -legacy streams; active POST requests are allowed to finish within the request -deadline. - -## Failure handling - -The old single-host configuration is not a 100,000-connection production -topology. It has one edge node, one Python process, and a low file-descriptor -limit. The new deployment fragments are reproducible examples, not proof of -capacity. Proof requires a load test with realistic Codex/Claude Code/Cursor -request sequences, 100,000 open edge connections, request bursts, graceful -reload, one node failure, and verification that no 500 is caused by connection -exhaustion. - -## Rejected alternatives - -- Raising `worker_connections` alone: it postpones exhaustion while leaving - unbounded SSE streams and stale workers in place. -- A hard per-IP connection quota as the main policy: coding agents share NAT - addresses and would be unfairly rejected. -- Keeping one backend socket per agent: it duplicates the edge connection - plane and makes horizontal scaling proportional to idle clients. -- Requiring clientInfo or User-Agent for authorization: both are untrusted and - spoofable. diff --git a/spec/designs/2026-09-03-mcp-combined-endpoint-design.md b/spec/designs/2026-09-03-mcp-combined-endpoint-design.md deleted file mode 100644 index e386cf6..0000000 --- a/spec/designs/2026-09-03-mcp-combined-endpoint-design.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -schema_version: 1 -kind: design -id: mcp-combined-endpoint -scope: product -requirements: - introduces: - - MCP_COMBINED_ENDPOINT_CAPABILITIES - - MCP_COMBINED_PAGE_READING_COMPATIBLE - uses: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_POST_ONLY_AGENT_TRANSPORT - - MCP_EDGE_CONNECTION_CAPACITY - - MCP_AGENT_REQUESTS_SCALE_HORIZONTALLY - - MCP_OVERLOAD_RETURNS_RETRYABLE_STATUS - - MCP_WORKER_DRAIN_IS_BOUNDED - - MCP_RESOURCE_VERSION_PAGE_READING_USES_RESOURCES - - MCP_RESOURCE_CATALOG_IS_PAGINATED - - MCP_RESOURCE_LIST_USES_STABLE_SNAPSHOTS - - MCP_RESOURCE_NOTIFICATIONS_ARE_OMITTED - - MCP_RESOURCE_LINKS_RESOLVE_TO_LISTED_RESOURCES - - MCP_RESOURCES_EXCLUDE_SUPPORT_PAGES - - MCP_TEMPLATES_EXCLUDE_LANGUAGE_AND_METHOD_SOURCES - replaces: - MCP_VERSIONS_FAIL_INDEPENDENTLY: MCP_COMBINED_ENDPOINT_CAPABILITIES - MCP_RESOURCE_VERSION_HAS_ONE_PRIMARY_PAGE_READER: MCP_COMBINED_PAGE_READING_COMPATIBLE - cancels: [] -decisions: - - adr:MCP_COMBINED_ENDPOINT -invariants: - - invariant:MCP_COMBINED_ENDPOINT_IS_SINGLE_RUNTIME -contracts: - - contract:MCP_API@2.2 -supersedes: - - design:mcp-v3-resource-contract - - design:mcp-100k-agent-capacity -cancels: [] ---- - -# Combined MCP endpoint - -## Требования - -### MCP_COMBINED_ENDPOINT_CAPABILITIES - -MCP v2 tools and the future resource-first capability profile are served by one -public `/mcp` endpoint and one horizontally scalable runtime. There is no -`/v3/mcp`, second MCP process, second systemd unit, or version-specific failure -domain. - -### MCP_COMBINED_PAGE_READING_COMPATIBLE - -The existing `v8std_get_page` tool remains available to v2 clients. Resources -are an additive page-reading surface for clients that support them; they do not -replace or hide the legacy tool. The server must not branch the tool catalog by -an application-version guess made from client metadata. - -## Решение - -The repository currently contains a working v2 server and only design-only v3 -artifacts. The v3 endpoint/process split is therefore retired before any v3 -runtime is built. Future resource catalog, templates, `resources/list`, and -`resources/read` functionality is added to `build_server()` in -`scripts/v8std_mcp_server.py` and deployed through the existing -`v8std-mcp.service`. - -The single endpoint exposes a superset capability surface: legacy tools remain -stable, while MCP `Resources` are advertised and used by capable clients. MCP -capabilities, not `clientInfo`, User-Agent, or a guessed application version, -determine which operation a client uses. - -The capacity and POST-only connection policy from -`design:mcp-100k-agent-capacity` applies to this combined runtime without a -second listener or upstream pool. - -## Почему это не «v2 и v3 на одном endpoint с negotiation» - -MCP protocol negotiation is not an application API-version selector. A single -endpoint must therefore publish one stable tool catalog. Calling the additive -Resources profile `MCP_API@3.0` while removing `v8std_get_page` would make the -same endpoint incompatible with existing clients. The combined contract keeps -the legacy tool and treats Resources as an additive capability instead. - -## Не входит в эту реализацию - -The full resource catalog implementation remains a separate vertical slice: -URI policy, stable snapshots, pagination, templates, links, and page-content -limits must be implemented and tested before the additive profile is advertised -as complete. This decision changes its runtime boundary now; it does not claim -that the unimplemented v3 resource catalog already exists. - -## Отклонённые альтернативы - -- separate `/v3/mcp` process and systemd unit; -- remove `v8std_get_page` from the shared endpoint; -- select application API versions from `clientInfo` or User-Agent; -- run two FastMCP servers behind one nginx location without a single combined - capability contract. diff --git a/spec/designs/2026-09-09-markdown-fence-rendering-design.md b/spec/designs/2026-09-09-markdown-fence-rendering-design.md deleted file mode 100644 index 2ba2b27..0000000 --- a/spec/designs/2026-09-09-markdown-fence-rendering-design.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -schema_version: 1 -kind: design -id: markdown-fence-rendering -scope: product -requirements: - introduces: - - MARKDOWN_FENCES_ARE_RENDERED_AS_BLOCKS - - MARKDOWN_RENDERING_PRESERVES_SOURCE_CORPUS - - MARKDOWN_RENDERING_PRESERVES_CODE_AND_CONTEXT - - ARTICLE_HTML_REJECTS_LAYOUT_BREAKING_STRUCTURE - - MARKDOWN_BUILD_AND_SERVE_SHARE_RENDERING - uses: - - DIAGNOSTIC_IDENTIFIERS_USE_SHARED_CHIPS - - DIAGNOSTIC_CHIPS_ARE_ACCESSIBLE - - GENERATED_DIAGNOSTIC_CHIPS_ARE_IDEMPOTENT - replaces: {} - cancels: [] -decisions: - - adr:NORMALIZE_FENCES_AT_RENDER_BOUNDARY -invariants: - - invariant:MARKDOWN_RENDERING_PRESERVES_SOURCE_AND_CODE -contracts: - - contract:ARTICLE_HTML@1.0 - - contract:DIAGNOSTIC_CHIP_MARKUP@1.0 -supersedes: [] -cancels: [] ---- - -# Корректный рендеринг блоков Markdown: issue #35 - -## Основание и границы доказательств - -Обращение: [#35 — Отображение страниц на сайте](https://github.com/zeegin/v8std/issues/35). -Исходная точка проектирования — локальный `main` -`f9e660bd901771660838c2a8358dba3c1710c56e`. - -9 сентября 2026 года дефект воспроизведён на публичной странице -`/diagnostics/bslls/SetPrivilegedMode/`, с поисковым параметром `h` и без него. -При ширине окна 1280 px область статьи шириной 930 px получила колонки -`1592.01px 0px`: текст основной колонки перестал нормально отображаться. - -Между абзацами и fenced-блоками в Markdown нет разделяющих пустых строк. -Используемый SuperFences требует такие разделители. В закреплённом окружении -Zensical 0.0.47 / Markdown 3.10.2 / PyMdown Extensions 11.0.1 получен HTML -с блочным `div.highlight` внутри `p`. Браузер закрывает абзац перед `div`; -последующий текст оказывается непосредственно в `article` и автоматически -размещается как анонимный элемент CSS Grid. - -Проверка существовавшей локальной сборки обнаружила вложенные блочные элементы -в абзацах на 42 из 1429 HTML-страниц: 32 BSLLS, 5 EDT/v8-code-style, -4 стандарта и страница встроенного языка. Это исходная структурная выборка, -не доказательство одинаковой визуальной поломки всех 42 страниц. Перед -реализацией требуется воспроизводимый baseline свежего рендеринга. - -Источники поведения: - -- [Правила SuperFences](https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#nested-fence-format). -- [Анонимные элементы CSS Grid](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Auto-placement#anonymous_grid_items). - -## Требования - -### MARKDOWN_FENCES_ARE_RENDERED_AS_BLOCKS - -Распознанный SuperFences блок остаётся отдельным HTML-блоком, даже если -соседний текст в исходнике не отделён пустой строкой. Нормализация границ -выполняется над рабочим представлением документа в Markdown-расширении, -до получения итогового HTML. Текст до и после блока остаётся в исходном -порядке и получает корректные контейнеры; код не оказывается внутри `p`. - -### MARKDOWN_RENDERING_PRESERVES_SOURCE_CORPUS - -Расширение не записывает файлы и не меняет содержимое `docs/`, managed source -blocks, provenance-маркеры или `data/diagnostic-sources.json`. -Существующая проверка хешей заимствованных статей остаётся обязательной и -не ослабляется. Markdown sidecars и AI/MCP-артефакты продолжают строиться из -исходного корпуса, а не из нормализованной для HTML копии. - -### MARKDOWN_RENDERING_PRESERVES_CODE_AND_CONTEXT - -Нормализация не добавляет и не удаляет строки внутри fenced-блока, не меняет -его отступы, язык, атрибуты и порядок. Существующие правила отображения -подсветчика сохраняются. Для корректно разделённого эквивалентного Markdown -результат копирования кода должен совпадать с результатом после исправления. - -Сохраняются вложенность списков, цитат, admonitions и tabs. Распознавание -backtick/tilde fences и их длины согласуется с используемым SuperFences; -inline code и indented code не переклассифицируются. Неполные конструкции -не исправляются выдуманным закрывающим fence. Повторное применение -нормализации не меняет результат. - -### ARTICLE_HTML_REJECTS_LAYOUT_BREAKING_STRUCTURE - -Проверка итогового HTML обнаруживает блочные элементы внутри абзацев статьи -и непустые непосредственные текстовые узлы внутри `article`, к которому -применяется существующая h6/grid-раскладка. Пробельные узлы и комментарии -не являются нарушением. Обход включает весь собранный корпус, а не allowlist -страниц исходного инцидента. - -Диагностика содержит путь файла, вид нарушения и его местоположение. -Нарушение даёт ненулевой код возврата и блокирует успешное завершение -публикуемой сборки. Проверка анализирует сериализованный HTML до исправлений -DOM браузером; строковые проверки наличия CSS-селекторов её не заменяют. - -### MARKDOWN_BUILD_AND_SERVE_SHARE_RENDERING - -Расширение подключается в общей конфигурации `zensical.toml` и используется -при начальной сборке и повторной генерации страницы в `serve`. -Коррекция не зависит от постобработки только режима `build`, таймера watcher, -браузерного JavaScript или поискового параметра. Проверка готового сайта -включается в release/build gate; живой предпросмотр не считается релизом. - -## Решение и состав изменения - -Атомарный выбор зафиксирован в -[ADR](../adr/2026-09-09-normalize-fences-at-render-boundary.md), сохранность -исходников и кода — в -[инварианте](../invariants/markdown-rendering-preserves-source-and-code.md), -граница renderer/CSS/browser — в -[контракте HTML](../contracts/article-html-v1-r0.md). - -Предполагаемые файлы реализации: - -- `scripts/v8std_markdown.py`: расширение с узкой задачей нормализации границ - уже распознаваемых fenced-блоков; не новый универсальный Markdown-парсер. -- `zensical.toml`: подключение расширения к существующему pipeline. -- `scripts/check_article_html.py`: read-only проверка собранных статей. -- `scripts/zensical_docs.sh`: обязательный вызов проверки после HTML-сборки. -- `tests/test_v8std_markdown.py` и `tests/test_article_html.py`: регрессии, - проверки сохранности и обнаружения ошибок; при необходимости точечные - дополнения существующих тестов build-wrapper. - -Расширение должно импортироваться в существующих локальном и Docker -окружениях. Версии зависимостей, CSS, шаблоны, исходные статьи, публичные -адреса и MCP-сервер не меняются. Если для выполнения требований понадобится -изменить эти границы, работа возвращается к пересмотру design. - -## Проверки результата - -Сначала воспроизводятся ошибочный HTML issue #35 и его корректно отделённый -эквивалент в реальной конфигурации проекта. Затем проверяются соседние и -вложенные fences, пустые блоки, fence-подобный текст внутри кода, специальные -HTML-символы, уже корректные документы и идемпотентность. - -Свежая полная сборка не должна содержать нарушений нового HTML-контракта. -Проверяются неизменность отслеживаемых исходников, хешей диагностик, порядка -заголовков, ID/якорей, адресов ссылок, diagnostic chips и текста копируемого -кода. Подключение расширения подтверждается и при `serve` после пересборки. - -Браузерная матрица: issue #35 с `h`/якорем и без них; представители BSLLS, -EDT, стандарты `std640`, `std643`, `std686`, `std726` и страница языка. -Ширины окна — 390, 768, 1024 и 1920 px; обе темы и работа без JavaScript. -Нет сжатия основной колонки в ноль и горизонтального переполнения страницы -из-за этой структуры; длинные строки кода прокручиваются внутри блока. -Подсветка поиска, переходы по якорям и копирование кода сохраняются. - -## Semantic impact и жизненный цикл - -Изменение нетривиально: вводит требования к рендерингу, инвариант сохранности -и наблюдаемый HTML-контракт. Существующий `DIAGNOSTIC_CHIP_MARKUP@1.0` -сохраняется без изменения ссылок, обязательных классов и доступности. -Действующие design/ADR/контракты не отменяются и не переписываются. - -Этот пакет описывает согласованное намерение, но не реализованное исправление. -Декларации новых fitness checks требуются при `IMPLEMENTED`: их модули -добавляются будущим plan, а не подменяются фиктивными зелёными тестами сейчас. -До реализации требуется согласование записанного пакета и отдельный plan. - -Перед локальным merge выполняются impact, повторная семантическая оценка, -merge-ready, применимые fitness checks, полный test suite и strict build. -Push и публикация сайта требуют отдельного запроса. MCP deployment не входит -в работу. Issue #35 остаётся открытым до исправления и проверки публичного -сайта после отдельно разрешённой публикации. diff --git a/spec/designs/2026-09-10-mcp-large-procedure-retrieval-design.md b/spec/designs/2026-09-10-mcp-large-procedure-retrieval-design.md deleted file mode 100644 index 2f18164..0000000 --- a/spec/designs/2026-09-10-mcp-large-procedure-retrieval-design.md +++ /dev/null @@ -1,269 +0,0 @@ ---- -schema_version: 1 -kind: design -id: mcp-large-procedure-retrieval -scope: product -requirements: - introduces: - - MCP_SNIPPET_ACCEPTED_INPUT_IS_SCANNED - - MCP_SNIPPET_TARGETS_SURVIVE_QUERY_BUDGET - - MCP_SNIPPET_INSTANCE_LIMIT_IS_DISCOVERABLE - - MCP_SNIPPET_RETRIEVAL_WORK_IS_BOUNDED - - MCP_SNIPPET_RESPONSE_STAYS_COMPACT - uses: - - MCP_LEGACY_VERSION_REMAINS_COMPATIBLE - - MCP_COMBINED_ENDPOINT_CAPABILITIES - - MCP_COMBINED_PAGE_READING_COMPATIBLE - - MCP_POST_ONLY_AGENT_TRANSPORT - replaces: {} - cancels: [] -decisions: - - adr:SNIPPET_SIGNALS_OUTSIDE_TEXT_QUERY -invariants: - - invariant:MCP_SNIPPET_SIGNALS_SURVIVE_TEXT_BUDGET -contracts: - - contract:MCP_API@2.3 -supersedes: [] -cancels: [] ---- - -# Крупные процедуры в explain_snippet: доведение PR #31 - -## Предложение на согласование - -Это кандидат design-пакета, не утверждение о готовой реализации. Plan создаётся -после письменного согласования. Изменять работающий MCP или публиковать ответы -во внешнем PR на этапе проектирования не требуется. - -Целевой ввод — одна процедура BSL или связанный фрагмент SDBL, включая крупные -процедуры. Обработка целых модулей, AST, межпроцедурный анализ и разбиение -репозитория на задания не входят в эту работу. - -## Основание - -Проверено на `main` `3df5b40e773d0e7bc146ac2d9214934bb4145f73`. -[PR #31](https://github.com/zeegin/v8std/pull/31), автор `andy24kr`, head -`6467f77661e855a9f73f571e13db4964bff9c199`, предлагает обрезать внутренний -поисковый текст до 500 символов. Пример из PR длиной 813 символов на исходном -`main` даёт `query is too long: max 500 characters`, хотя snippet допускает 4000. - -Причина — смешение двух контрактов: допустимый код передаётся как обычный -поисковый запрос с меньшим лимитом. Простое увеличение общего `MAX_QUERY_CHARS` -расширило бы стоимость публичного поиска и не требуется для исправления. - -В PR ошибочно утверждается, что признаки идут первыми: фактический порядок — -`normalized_text`, значения признаков, их `target_ids`. При эмуляции предложенного -усечения на неизменённом индексе признак привилегированного режима остаётся в -`signals`, но `std485` отсутствует в рекомендациях для процедуры длиной 1122 -символа; аналогично `std740` для пароля в процедуре длиной 1169 символов. -Проверки только отсутствия исключения недостаточно. - -Анализатор уже читает полный snippet, а возвращает `normalized_text[:1000]` -и первые 80 уникальных токенов. Дедупликация токенов уже использует `set`; -квадратичную дедупликацию исправлять здесь не нужно. Не ограничена сумма длин -возвращаемых токенов; одинаковый SDBL-признак может повторяться для разных строк -запроса. Эти особенности существенны при увеличении входного окна. - -## Альтернативы - -| Подход | Результат и ограничение | -|---|---| -| Только усечение из PR | Убирает исключение, но теряет релевантность; перестановка признаков перед кодом также не гарантирует сохранения всех целей. | -| Поиск по каждому блоку или признаку | Даёт больше контекста, но число дорогих поисков растёт с размером процедуры; нужен отдельный контракт агрегирования и бюджета. | -| Полное извлечение признаков + один ограниченный поиск | Рекомендуется: явные цели не зависят от текстового бюджета; сохраняются текущий индекс и публичный поиск. | - -## Требования - -### MCP_SNIPPET_ACCEPTED_INPUT_IS_SCANNED - -Лимит проверяется по исходной строке до нормализации. Все принятые символы -поступают в существующее извлечение признаков: важный вызов в конце не теряется -из-за обрезки preview или поискового текста. При превышении лимита — явная -ошибка с фактическим пределом, без молчаливого усечения исходного кода. - -Речь о полноте запуска существующих распознавателей, а не о полноте анализа -семантики программы. Regex-эвристики не становятся AST-анализатором; отсутствие -признаков не означает отсутствие нарушений. Декларации языка `auto/bsl/sdbl` -и текущее поведение пустого ввода сохраняются. - -### MCP_SNIPPET_TARGETS_SURVIVE_QUERY_BUDGET - -Результат анализа имеет два независимых внутренних потребителя: - -1. Текстовый контекст: нормализованное начало кода, усечённое до 500 символов - по границе слова, с жёстким срезом, если единственное слово длиннее бюджета. - Это вспомогательный контекст, не представление всей процедуры. -2. Уникальные структурные цели признаков: разрешение `target_ids` непосредственно - в уже загруженном индексе, без превращения ID в длинную поисковую строку. - -Один `search(..., mode="hybrid")` получает только ограниченный контекст, -запрашивает не более `limit` результатов типов diagnostic/standard/pattern. -Его результаты объединяются по ID с разрешёнными целями признаков. Не нужны -новый поисковый сервис, изменение общего ранжировщика или N вызовов `search`. - -Перед общим top-K сначала идут первичные цели распознанных правил, затем их -дополнительные цели, затем остальные поисковые рекомендации. Для `snippet_call` -первичная цель берётся из `rule.primary`; для встроенного SDBL/secret-признака — -первый `target_id`. Если цель одновременно первичная и дополнительная, -сохраняется первичный приоритет. Отсутствующая в индексе цель не создаёт -выдуманную страницу и не прерывает обработку остальных. - -Прямой сигнал даёт кандидату один, не суммируемый по числу повторений бонус: -4200 для первичной цели или 2200 для дополнительной, как существующая шкала -правил поиска. Бонус прибавляется к имеющемуся поисковому score либо к нулю -для нового кандидата; причина `snippet_signal:` и числовой вклад -`score_details.snippet_signal` объясняют происхождение. Сортировка — класс -приоритета, убывание итогового score, существующий `concrete_rank`, тип и ID. -Класс приоритета, а не величина бонуса, защищает от вытеснения шумным текстом. - -После общего top-K сохраняется разделение на `diagnostics` и `standards` -(последнее также содержит patterns). Их суммарный размер не превышает `limit`, -как в действующем поведении; не вводятся два независимых бюджета по K. -Если первичных целей больше K, все они участвуют в ранжировании, но вернуть -их все невозможно. При K не меньше их числа каждая существующая первичная -цель обязана попасть в ответ. Повтор одного сигнала не повышает его вес. - -### MCP_SNIPPET_INSTANCE_LIMIT_IS_DISCOVERABLE - -Предлагаемые значения для этой поставки: - -| Настройка | Значение | -|---|---| -| Значение по умолчанию | 4000 Unicode code points | -| Локальная расширенная настройка | `V8STD_MCP_MAX_SNIPPET_CHARS=32000` | -| Допустимый диапазон | целое от 4000 до 32000 включительно | -| CLI | `--max-snippet-chars`, приоритет над env, затем default | -| Обычный `v8std_search.query` | прежние 500 символов независимо от настройки | - -32 000 — предложенный защитный потолок, а не измеренная граница оборудования. -Изменение на лету и автоматическое определение «локальности» по bind address, -IP или клиенту не нужны: локальный Docker тоже слушает `0.0.0.0` внутри контейнера. - -Конфигурация разрешается один раз при старте до загрузки индекса/открытия -listener. Не выбранный из-за CLI источник env не участвует в проверке. -Выбранное значение после удаления внешних пробелов должно состоять из ASCII -цифр и попадать в диапазон; пустое, отрицательное, дробное, нечисловое или -выходящее за диапазон значение завершает запуск ненулевым кодом без fallback. - -Эффективный лимит хранится в экземпляре `V8StdIndex`, валидируется также при -прямом создании индекса и является единственным источником для проверки ввода, -`tools/list.inputSchema.properties.snippet.maxLength` и описания инструмента. -SDK-схема создаётся с вычисленной аннотацией/Field при регистрации функции; -строковая аннотация с локальной переменной при `from __future__ import -annotations` не должна приводить к NameError. Private API SDK не требуется. - -Для размера Field публикует schema-only `json_schema_extra.maxLength`, а -проверку выполняет индекс: стандартный Pydantic error от `max_length` может -включать `input_value`, то есть присланный код. Свой ValueError сообщает только -имя аргумента и предел. В установленном SDK проверено получение `maxLength` -через `func_metadata` при такой аннотации без преждевременной size-валидации; -полный MCP wire/error путь ещё подлежит conformance. Проверка типов SDK -сохраняется; новый контракт отсутствия кода в size-error не обещает общей -очистки всех существующих SDK validation errors. - -Direct Python, shell wrapper и Docker Compose используют один серверный -механизм чтения env. Compose явно пробрасывает значение: экспорт переменной -на хосте сам по себе не передаёт её контейнеру. Шаблон публичного systemd unit -закрепляет `--max-snippet-chars 4000`; nginx и публичные transport-бюджеты не -увеличиваются. Это изменение шаблона, не разрешение на deployment. - -Агент видит предел до вызова и указание передавать одну релевантную процедуру, -а не весь модуль. При ошибке размера повтор того же запроса бесполезен: -уменьшить фрагмент или явно настроить собственный сервер и перезапустить его. -Не предлагать агентам автоматически вычитывать все страницы или делить весь -модуль на множество параллельных запросов. - -### MCP_SNIPPET_RETRIEVAL_WORK_IS_BOUNDED - -На запрос выполняется один гибридный поиск (допускается пропуск при пустом -контексте), независимо от длины процедуры и числа сигналов. Разрешение целей -использует локальную карту страниц, а не дополнительные поиски. Не добавляются -сетевые вызовы за исключением существующего refresh индекса, LLM, очереди, -сессии, воркеры или новые endpoint. - -Извлечение признаков обрабатывает не более настроенного N, ограниченного 32k; -число распознавателей ограничено загруженным каталогом правил. Сохраняется -set-based дедупликация. Ни генерация query, ни повтор сигнала не размножают -BM25/semantic проходы по индексу. Это структурное ограничение стоимости, -не доказательство линейности каждого regex или производственного SLA. - -Лимит строки не равен лимиту HTTP body: у JSON с escaped Unicode один символ -может занимать до 12 байт. Локальный прямой HTTP/контейнер должен принимать -32k в UTF-8 и escaped-представлении. При собственном reverse proxy оператору -нужно отдельно выставить достаточный byte-limit; для одной 32k строки с обычным -конвертом ориентир — 512 KiB. Публичные 256k nginx остаются прежними. Произвольные -лишние JSON-поля/whitespace этим ориентиром не покрываются. - -### MCP_SNIPPET_RESPONSE_STAYS_COMPACT - -Форма ответа и типы существующих полей сохраняются. Preview не используется -как вход распознавания: `normalized_text` не более 1000 символов; `tokens` — -первые уникальные токены в исходном порядке, не более 80 элементов и суммарно -4000 символов. При переполнении суммарного бюджета возвращается помещающийся -префикс целых токенов; содержимое токенов не подрезается и не выдумывается. -Для прежнего допустимого 4k-ввода дополнительный бюджет не обрезает токены. - -Идентичные `signals` дедуплицируются по type/rule/value/ordered target_ids -с сохранением первого вхождения. Список описывает виды признаков, не число -нарушений или позиции: такая семантика уже используется для `snippet_call`. -Число повторов одного SQL-фрагмента больше не раздувает ответ. Иные сигналы -не удаляются. Полный код, статьи и новые bulk Resources в ответ не добавляются. - -`confidence` остаётся существующей эвристикой 0..1 по наличию сигналов и score -первой итоговой рекомендации; это не вероятность корректности диагноза. -Логи usage по-прежнему не содержат текст процедуры или литералы. Preview -возвращается вызывающему клиенту как прежде и не считается обезличиванием кода. - -## Проверки, необходимые перед интеграцией - -| Проверка | Условие прохождения | -|---|---| -| Исходный пример автора | 813 символов принимаются; содержательная рекомендация `std498` присутствует, а не только поля ответа. | -| Границы | N−1/N приняты, N+1 отклонён для 4000 и 32000; `search` по-прежнему отклоняет 501. Unicode считается после JSON-декодирования. | -| Позиционная устойчивость | Для secret/std740, privileged/std485, SDBL/std415 и modal/UsingModalWindows признаки в начале, середине и конце 4k/32k дают соответствующую первичную цель при K=1 для одиночного признака. | -| Несколько признаков | Все первичные цели входят при достаточном K; при малом K детерминированный top-K; общий лимит и отсутствие дублей сохранены. | -| Полнота кандидатов | Отсутствующая цель не вызывает сбой; существующая не теряется из-за 500-символьного бюджета или постороннего высокого score. | -| Ложные сигналы | Идентификатор `Предупреждение` без вызова не становится `snippet_call`; существующие negative fixtures сохранены. | -| Стоимость | Spy подтверждает не более одного `search`, query ≤500 при 4k/32k, многих разных и повторяющихся сигналах. | -| Компактность | 32k одно длинное слово, много уникальных токенов и повторов SDBL не нарушают preview/token budgets; сигнал в хвосте найден даже при пустом token preview. | -| Конфигурация и wire | Default/env/CLI, невалидные значения, два индекса с разными лимитами в одном процессе; `initialize`, `tools/list`, `tools/call` согласованы. Error не содержит исходную процедуру. | -| Способы запуска | Python, wrapper и Compose дают одинаковый effective limit; публичный шаблон закрепляет 4000; UTF-8/escaped 32k проходят локальный HTTP. | -| Совместимость | Пять tool names, прежние обязательные аргументы, форма ответов, POST-only и единый `/mcp` сохранены; отсутствует новый bulk-доступ. | - -Дополнить существующий retrieval benchmark позиционными случаями и проверить -его текущие quality thresholds, не ослабляя их. Обычный поиск на прежнем корпусе -и запросах должен возвращать прежние ID/порядок; snippet-ranking меняется намеренно. - -Отдельно записать воспроизводимый локальный latency baseline: один процесс, -прогретый неизменный индекс, 20 прогревов и 200 измерений на сценарий, три серии. -Для коротких, успешно работающих на исходном main snippets регрессия медианы -трёх p95 не больше `max(20% baseline, 10 ms)`. Для пары новых 4k/32k fixtures -с одинаковым началом и набором сигналов добавочный p95 32k не больше -`max(25% p95_4k, 20 ms)`. Это предлагаемый инженерный gate, ещё не измеренный -результат. Провал возвращает решение на пересмотр, а не автоматически поднимает -бюджеты. Проверить также отключённые vectors и необычные кавычки/длинные токены. - -## Границы изменения и завершение PR - -Реализация затрагивает `scripts/v8std_mcp_index.py`, -`scripts/v8std_retrieval_rules.py`, `scripts/v8std_mcp_server.py`, локальную -Compose-конфигурацию, публичный systemd-шаблон, документацию MCP и тесты. -Wrapper меняется только если smoke выявит необходимость: дублировать парсер -env в shell не следует. Корпус статей, веса общего поиска, Resources и образ -из PR #33 не входят в пакет. - -После согласования нужен отдельный plan через `superpowers:writing-plans`. -Сначала сохраняется regression автора и добавляются RED-проверки релевантности; -затем ограниченный поиск/цели, конфигурация/discovery и проверки всех способов -запуска. Это порядок вертикальных частей решения, не принятый execution plan. - -Исходный commit PR включается в историю рабочей ветки локальным объединением, -с сохранением автора и co-author. Наши доработки — отдельные commits; не -переписывать вклад автора своим именем. Перед этим повторно сверить head PR. -После semantic impact check, architecture merge-ready, fitness, полного suite -и strict build выполняется локальный merge. Push требует явного запроса; -MCP deployment — отдельного запроса для проверенного SHA из main. - -Не смешивать эту работу с Docker publishing #33, редполитикой #15, комментариями -#18, расширением Resources или заявлением о 100 000 одновременных агентов. -Нет измерений, позволяющих приписать данному исправлению такую ёмкость. diff --git a/spec/invariants/markdown-rendering-preserves-source-and-code.md b/spec/invariants/markdown-rendering-preserves-source-and-code.md deleted file mode 100644 index 0b7ca04..0000000 --- a/spec/invariants/markdown-rendering-preserves-source-and-code.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MARKDOWN_RENDERING_PRESERVES_SOURCE_AND_CODE -scope: product -introduced_by: adr:NORMALIZE_FENCES_AT_RENDER_BOUNDARY -requirements: - - MARKDOWN_RENDERING_PRESERVES_SOURCE_CORPUS - - MARKDOWN_RENDERING_PRESERVES_CODE_AND_CONTEXT -owner: v8std maintainers -governs: - - scripts/v8std_markdown.py - - zensical.toml - - tests/test_v8std_markdown.py -check: - module: tests.test_v8std_markdown - command: .venv/bin/python -m unittest tests.test_v8std_markdown -v -required_when: implemented ---- - -# Рендеринг сохраняет исходный корпус и код - -При одинаковом входном корпусе включение нормализации fenced-блоков не меняет -исходные Markdown-файлы, provenance-маркеры, хеши managed source blocks и -данные каталога источников. Нормализованная копия не становится источником -для Markdown sidecars, AI-артефактов или MCP. - -Тело каждого блока кода сохраняется без вставки/удаления строк, изменения -отступов или языка. Поведение существующего подсветчика не меняется: -копируемый код сравнивается с корректно разделённым эквивалентом, а не с -заведомо сломанной HTML-структурой. - -Fitness module проверяет эти свойства на фиксированных fixtures и реальном -renderer, включая повторное применение нормализации. Перед выпуском к нему -добавляются существующая `scripts/check_diagnostic_articles.py`, проверка -неизменности исходного корпуса после сборки и браузерное копирование кода. -Декларация будущего модуля не является свидетельством готового исправления. diff --git a/spec/invariants/mcp-combined-endpoint-is-single-runtime.md b/spec/invariants/mcp-combined-endpoint-is-single-runtime.md deleted file mode 100644 index 76f8c29..0000000 --- a/spec/invariants/mcp-combined-endpoint-is-single-runtime.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_COMBINED_ENDPOINT_IS_SINGLE_RUNTIME -scope: product -introduced_by: adr:MCP_COMBINED_ENDPOINT -requirements: - - MCP_COMBINED_ENDPOINT_CAPABILITIES -owner: v8std maintainers -governs: - - scripts/v8std_mcp_server.py - - deploy/nginx/server-v8std-mcp.conf - - deploy/systemd/v8std-mcp.service - - tests/test_v8std_mcp_combined.py -check: - module: tests.test_v8std_mcp_combined - command: .venv/bin/python -m unittest tests.test_v8std_mcp_combined -v -required_when: implemented ---- - -# MCP uses one combined runtime - -`/mcp` is served by one `v8std_mcp_server.py` runtime and the existing -`v8std-mcp.service`. No `/v3/mcp`, v3-specific listener, or v3-specific unit is -part of the deployable topology. Future Resources are additive capabilities of -the same runtime. diff --git a/spec/invariants/mcp-edge-drain-is-bounded.md b/spec/invariants/mcp-edge-drain-is-bounded.md deleted file mode 100644 index e58d4dc..0000000 --- a/spec/invariants/mcp-edge-drain-is-bounded.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_EDGE_DRAIN_IS_BOUNDED -scope: product -introduced_by: adr:MCP_POST_ONLY_EDGE_DRAIN -requirements: [MCP_WORKER_DRAIN_IS_BOUNDED] -owner: v8std maintainers -governs: - - deploy/nginx/nginx.conf.capacity-example - - deploy/nginx/http-v8std-mcp.conf - - deploy/systemd/v8std-mcp.service - - tests/test_v8std_mcp_capacity.py -check: - module: tests.test_v8std_mcp_capacity - command: .venv/bin/python -m unittest tests.test_v8std_mcp_capacity -v -required_when: implemented ---- - -# Edge workers have a finite drain deadline - -The capacity deployment sets `worker_shutdown_timeout 30s`, an effective nginx -file-descriptor limit of at least 131,072, a 40,000-connection admission cap -per edge node, and a matching MCP service limit. Existing long-lived streams -cannot keep an old worker alive indefinitely after reload. diff --git a/spec/invariants/mcp-legacy-endpoint-stability.md b/spec/invariants/mcp-legacy-endpoint-stability.md deleted file mode 100644 index c278595..0000000 --- a/spec/invariants/mcp-legacy-endpoint-stability.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_LEGACY_ENDPOINT_STABILITY -scope: product -introduced_by: adr:MCP_VERSION_ENDPOINT_ISOLATION -requirements: [MCP_LEGACY_VERSION_REMAINS_COMPATIBLE] -owner: v8std maintainers -governs: - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_index.py -check: - module: tests.test_v8std_mcp_server - command: .venv/bin/python -m unittest tests.test_v8std_mcp_server tests.test_v8std_mcp_index -v -required_when: accepted ---- - -# Legacy MCP endpoint remains stable - -`/mcp` продолжает предоставлять действующий MCP v2 contract независимо от -наличия v3. Изменение legacy tool names, inputs или result shape как побочный -эффект v3 опровергает решение об изоляции endpoint. diff --git a/spec/invariants/mcp-overload-is-retryable.md b/spec/invariants/mcp-overload-is-retryable.md deleted file mode 100644 index 7ebef24..0000000 --- a/spec/invariants/mcp-overload-is-retryable.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_OVERLOAD_IS_RETRYABLE -scope: product -introduced_by: adr:MCP_POST_ONLY_EDGE_DRAIN -requirements: [MCP_OVERLOAD_RETURNS_RETRYABLE_STATUS] -owner: v8std maintainers -governs: - - deploy/nginx/server-v8std-mcp.conf - - spec/contracts/mcp-api-v2-r1.md - - tests/test_v8std_mcp_capacity.py -check: - module: tests.test_v8std_mcp_capacity - command: .venv/bin/python -m unittest tests.test_v8std_mcp_capacity -v -required_when: implemented ---- - -# Admission failures are explicit and retryable - -Request-rate admission returns 429; connection admission and backend -unavailability return 503. Upstream 502/504 failures are normalized to 503 at -the edge. Connection exhaustion must not be surfaced as a generic nginx 500. -The edge uses `Retry-After` policy at the deployment boundary and keeps -overload observable in metrics and access logs. diff --git a/spec/invariants/mcp-post-only-transport.md b/spec/invariants/mcp-post-only-transport.md deleted file mode 100644 index 5199188..0000000 --- a/spec/invariants/mcp-post-only-transport.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_POST_ONLY_TRANSPORT -scope: product -introduced_by: adr:MCP_POST_ONLY_EDGE_DRAIN -requirements: [MCP_POST_ONLY_AGENT_TRANSPORT] -owner: v8std maintainers -governs: - - scripts/v8std_mcp_server.py - - tests/test_v8std_mcp_server.py -check: - module: tests.test_v8std_mcp_server - command: .venv/bin/python -m unittest tests.test_v8std_mcp_server -v -required_when: implemented ---- - -# Stateless MCP does not retain unsolicited SSE streams - -`GET /mcp` with an `Accept` header containing `text/event-stream` returns HTTP -405 and `Allow: POST, HEAD`. Browser self-documentation and normal JSON-RPC -POST requests remain available. The server does not create a long-lived -connection for an agent that is idle between tool calls. diff --git a/spec/invariants/mcp-resource-links-are-listable.md b/spec/invariants/mcp-resource-links-are-listable.md deleted file mode 100644 index 5ba2264..0000000 --- a/spec/invariants/mcp-resource-links-are-listable.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_RESOURCE_LINKS_ARE_LISTABLE -scope: product -introduced_by: adr:PAGE_READING_VIA_RESOURCES -requirements: - - MCP_RESOURCE_LINKS_RESOLVE_TO_LISTED_RESOURCES - - MCP_RESOURCE_CATALOG_IS_PAGINATED -owner: v8std maintainers -governs: - - scripts/v8std_mcp_v3.py - - scripts/v8std_mcp_resources.py -check: - module: tests.test_v8std_mcp_v3 - command: .venv/bin/python -m unittest tests.test_v8std_mcp_v3 -v -required_when: implemented ---- - -# Resource links belong to the listable catalog - -Каждый URI, возвращаемый tool как resource link, присутствует в стабильном -пагинируемом snapshot и читается через `resources/read`. Скрытая или -неразрешимая ссылка опровергает выбранный resource-first contract. diff --git a/spec/invariants/mcp-resource-version-page-reading-via-resources.md b/spec/invariants/mcp-resource-version-page-reading-via-resources.md deleted file mode 100644 index 527c471..0000000 --- a/spec/invariants/mcp-resource-version-page-reading-via-resources.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_RESOURCE_VERSION_PAGE_READING_VIA_RESOURCES -scope: product -introduced_by: adr:PAGE_READING_VIA_RESOURCES -requirements: - - MCP_RESOURCE_VERSION_PAGE_READING_USES_RESOURCES - - MCP_RESOURCE_VERSION_HAS_ONE_PRIMARY_PAGE_READER -owner: v8std maintainers -governs: - - scripts/v8std_mcp_v3.py - - scripts/v8std_mcp_resources.py -check: - module: tests.test_v8std_mcp_v3 - command: .venv/bin/python -m unittest tests.test_v8std_mcp_v3 -v -required_when: implemented ---- - -# MCP v3 reads pages only through Resources - -В MCP v3 page content читается `resources/read`, а отдельный -`v8std_get_page` отсутствует. Второй равноправный page reader или отсутствие -resource reading опровергает `PAGE_READING_VIA_RESOURCES`. diff --git a/spec/invariants/mcp-snippet-signals-survive-text-budget.md b/spec/invariants/mcp-snippet-signals-survive-text-budget.md deleted file mode 100644 index df4e01c..0000000 --- a/spec/invariants/mcp-snippet-signals-survive-text-budget.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_SNIPPET_SIGNALS_SURVIVE_TEXT_BUDGET -scope: product -introduced_by: adr:SNIPPET_SIGNALS_OUTSIDE_TEXT_QUERY -requirements: - - MCP_SNIPPET_ACCEPTED_INPUT_IS_SCANNED - - MCP_SNIPPET_TARGETS_SURVIVE_QUERY_BUDGET - - MCP_SNIPPET_RETRIEVAL_WORK_IS_BOUNDED -owner: v8std maintainers -governs: - - scripts/v8std_mcp_index.py - - scripts/v8std_retrieval_rules.py - - tests/test_v8std_mcp_index.py - - tests/test_v8std_mcp_snippet.py -check: - module: tests.test_v8std_mcp_snippet - command: .venv/bin/python -m unittest tests.test_v8std_mcp_snippet -v -required_when: implemented ---- - -# Сигналы процедуры не теряются из-за бюджета текстового поиска - -Принятый snippet полностью поступает в распознавание. Существующая в индексе -первичная цель найденного признака участвует в приоритетном ранжировании -независимо от позиции в snippet и попадания в 500-символьный контекст. -Если K не меньше числа уникальных существующих первичных целей, каждая -присутствует в общем top-K. Если K меньше — применяется определённая design -детерминированная сортировка, без обещания выдать больше K. - -Длина кода и количество признаков не увеличивают число вызовов гибридного -поиска выше одного. Повторение одинакового признака не увеличивает его вес. -Fitness включает позиции начала/середины/конца, 4k/32k, конфликт с высоким -текстовым score, повторы и малый K, а не только отсутствие исключений. - -Декларация fitness сама по себе не является результатом теста или -свидетельством IMPLEMENTED. Выполненные проверки модуля и остальных gates -зафиксированы в [verification evidence](../operations/2026-09-10-mcp-snippet-verification.md). diff --git a/spec/invariants/mcp-version-isolation.md b/spec/invariants/mcp-version-isolation.md deleted file mode 100644 index 2bbd5d1..0000000 --- a/spec/invariants/mcp-version-isolation.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: MCP_VERSION_ISOLATION -scope: product -introduced_by: adr:MCP_VERSION_ENDPOINT_ISOLATION -requirements: [MCP_VERSIONS_FAIL_INDEPENDENTLY] -owner: v8std maintainers -governs: - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_v3.py -check: - module: tests.test_v8std_mcp_versions - command: .venv/bin/python -m unittest tests.test_v8std_mcp_versions -v -required_when: implemented ---- - -# MCP versions fail independently - -Отказ запуска, маршрутизации или каталога `/v3/mcp` не изменяет доступность -`/mcp`. Общий failure domain, из-за которого новая версия останавливает legacy -endpoint, опровергает решение `MCP_VERSION_ENDPOINT_ISOLATION`. diff --git a/spec/invariants/metrics-endpoints-are-loopback-only.md b/spec/invariants/metrics-endpoints-are-loopback-only.md deleted file mode 100644 index 0d27b66..0000000 --- a/spec/invariants/metrics-endpoints-are-loopback-only.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: METRICS_ENDPOINTS_ARE_LOOPBACK_ONLY -scope: product -introduced_by: adr:LOCAL_OPENMETRICS_EXPOSITION -requirements: [METRICS_ENDPOINTS_USE_LOOPBACK] -owner: v8std maintainers -governs: - - scripts/v8std_mcp_metrics.py - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_v3.py -check: - module: tests.test_v8std_mcp_metrics - command: .venv/bin/python -m unittest tests.test_v8std_mcp_metrics -v -required_when: implemented ---- - -# Metrics endpoints bind only to loopback - -OpenMetrics exposition слушает loopback и не маршрутизируется публичным proxy. -Внешний bind или публикация `/metrics` опровергает решение о локальной -генерации. diff --git a/spec/invariants/metrics-label-cardinality-is-bounded.md b/spec/invariants/metrics-label-cardinality-is-bounded.md deleted file mode 100644 index 877b922..0000000 --- a/spec/invariants/metrics-label-cardinality-is-bounded.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: METRICS_LABEL_CARDINALITY_IS_BOUNDED -scope: product -introduced_by: adr:LOCAL_OPENMETRICS_EXPOSITION -requirements: [METRICS_EXCLUDE_HIGH_CARDINALITY_LABELS] -owner: v8std maintainers -governs: - - scripts/v8std_mcp_metrics.py - - scripts/v8std_mcp_server.py - - scripts/v8std_mcp_v3.py -check: - module: tests.test_v8std_mcp_metrics - command: .venv/bin/python -m unittest tests.test_v8std_mcp_metrics -v -required_when: implemented ---- - -# Metric label cardinality is bounded - -Labels выбираются из конечных нормализованных множеств и не содержат IP, -user-agent, request ID, cursor, URI или query text. Неограниченный label -опровергает безопасность и эксплуатационную пригодность OpenMetrics contract. diff --git a/spec/invariants/operator-data-stays-outside-web-root.md b/spec/invariants/operator-data-stays-outside-web-root.md deleted file mode 100644 index 46c99a2..0000000 --- a/spec/invariants/operator-data-stays-outside-web-root.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: OPERATOR_DATA_STAYS_OUTSIDE_WEB_ROOT -scope: product -introduced_by: adr:PUBLIC_MCP_MONITORING -requirements: [OPERATOR_DETAILS_STAY_OUTSIDE_WEB_ROOT] -owner: v8std maintainers -governs: - - scripts/v8std_mcp_monitoring.py -check: - module: tests.test_v8std_mcp_monitoring - command: .venv/bin/python -m unittest tests.test_v8std_mcp_monitoring -v -required_when: implemented ---- - -# Operator detail never enters the web root - -Сырые события и детальные operator-only отчёты записываются вне публикуемого -дерева с ограниченными правами. Копирование этих данных в static site или -доступный Nginx path опровергает границу публичного monitoring. diff --git a/spec/invariants/public-monitoring-excludes-sensitive-data.md b/spec/invariants/public-monitoring-excludes-sensitive-data.md deleted file mode 100644 index f8d90a1..0000000 --- a/spec/invariants/public-monitoring-excludes-sensitive-data.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -schema_version: 1 -kind: invariant -id: PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA -scope: product -introduced_by: adr:PUBLIC_MCP_MONITORING -requirements: [PUBLIC_MONITORING_EXCLUDES_SENSITIVE_DATA] -owner: v8std maintainers -governs: - - scripts/v8std_mcp_monitoring.py - - docs/monitoring/ -check: - module: tests.test_v8std_mcp_monitoring - command: .venv/bin/python -m unittest tests.test_v8std_mcp_monitoring -v -required_when: implemented ---- - -# Public monitoring contains only safe aggregates - -Публичная проекция не раскрывает IP, сырой user-agent, request ID, cursor, URI, -query text или идентифицируемые малые срезы. Появление любого такого значения -опровергает решение оставить monitoring публичным. diff --git a/spec/local-site-build-files.md b/spec/local-site-build-files.md new file mode 100644 index 0000000..972e895 --- /dev/null +++ b/spec/local-site-build-files.md @@ -0,0 +1,157 @@ +# Файлы для локальной сборки сайта + +Состав проверен 17 сентября 2026 года. Реестр определяет границу сборки сайта +после разделения каталогов разработки, сборки и поставки. Целевая поставка описана +в [delivery-target.md](delivery-target.md). + +Это инвентаризация текущей реализации, а не новая структура каталогов. +«Не требуется для сборки сайта» не означает «не нужно проекту» или «удалить». +Согласованные перемещения выполнены; текущее размещение и оставшиеся работы +описаны в [плане](repository-layout-plan.md). + +## Что считаем локальной сборкой + +Сборка текущего сайта из контента и шаблонов на машине разработчика, без +MCP-сервера, VPS, Docker и CI. Входная команда — `scripts/zensical_docs.sh`. +Результат — `site/`; для разработки предусмотрена команда `serve`. +Это отличается от сборки поставляемого Docker-образа локального сайта. + +## Обязательные входы текущей команды + +Пути указаны относительно корня репозитория. Каталоги перечислены как группы +входов: это список для сборки всего сайта, а не одной выбранной страницы. + +| Файл или группа | Зачем нужны | +|---|---| +| Отслеживаемые Git файлы `docs/` | Публикуемый контент: страницы, изображения, CSS, JavaScript и прочие статические ресурсы; 1762 существующих отслеживаемых файла на дату проверки | +| `zensical.toml` | Настройки сайта, навигация, тема, Markdown-расширения | +| `overrides/home.html` | Шаблон главной страницы | +| `overrides/main.html` | Общий шаблон сайта | +| `overrides/partials/social_meta.html` | Метаданные страниц | +| `data/diagnostic-sources.json`, `data/acc-diagnostics.json` | Каталоги диагностик для дополнения sitemap | +| `retrieval-rules.yml` | Правила и псевдонимы для создаваемого AI-корпуса | +| `LICENSES/EPL-2.0.txt` | Публикуемый текст лицензии | +| `LICENSES/GPL-3.0.txt` | Публикуемый текст лицензии | +| `LICENSES/LGPL-3.0.txt` | Публикуемый текст лицензии | + +В `docs/` необходимы и изображения логотипов, включая +`docs/assets/images/logo-social.png`: их используют настройки и генератор карточек. +Готовые статьи находятся в `docs/`, но два перечисленных каталога из `data/` +также читаются при сборке sitemap. Остальные файлы data в этот маршрут не входят. + +### Код сборки: 14 файлов + +| Файл в `scripts/` | Роль | +|---|---| +| `zensical_docs.sh` | Запускает генераторы, Zensical и обработку результата; поддерживает build и serve | +| `zensical-version.sh` | Закреплённая версия Zensical: 0.0.47 | +| `generate_social_cards.py` | Карточки, метаданные страниц и robots.txt | +| `generate_ai_artifacts.py` | AI-корпус, llms-файлы и Markdown-копии страниц | +| `generate_search_vectors.py` | Поисковые векторы | +| `v8std_retrieval_rules.py` | Чтение retrieval-rules.yml и токенизация | +| `v8std_search_features.py` | Формирование поисковых псевдонимов | +| `v8std_mcp_chunks.py` | Разбиение страниц для поисковых векторов; нужен сборке, несмотря на MCP в имени | +| `atomic_files.py` | Запись результатов генерации | +| `v8std_markdown.py` | Markdown-расширение, подключённое в zensical.toml | +| `check_article_html.py` | Проверка HTML, обязательная внутри текущей команды build | +| `diagnostic_inventory.py` | Читает два каталога data для построения адресов диагностик | +| `publish_diagnostic_sitemap.py` | Обработка sitemap после сборки | +| `publish_license_texts.py` | Копирование и проверка текстов лицензий | + +Это минимальный набор зависимостей существующего маршрута, выявленный по вызовам +и импортам. Он сохраняет его поведение целиком. Теоретический минимум для вывода +HTML без AI-файлов и карточек потребует изменения самого маршрута. + +### Окружение и установка + +- Python 3.12+, Bash и установленные Python-зависимости сборки. +- `requirements-build.lock` — закреплённые зависимости сборки, включая Zensical. + Для отдельного окружения достаточно установки этого файла; MCP-зависимости + не требуются. +- Альтернативный существующий установщик: `scripts/install_zensical.sh`, + `scripts/zensical-version.sh` и `requirements.txt`. Он устанавливает Zensical + отдельно, остальные зависимости — через requirements.txt. Это альтернативный + путь установки, а не дополнительные обязательные входы установленной сборки. +- Шрифт для социальных карточек: генератор ищет системные DejaVu/Arial. + Воспроизводимый внешний вид требует одинаковых шрифтов; контейнер CI закрепляет + DejaVu, локальная macOS-сборка может использовать Arial. + +После подготовки окружения, из корня проекта: + +```bash +VIRTUAL_ENV="$PWD/.venv" bash scripts/zensical_docs.sh build --strict +VIRTUAL_ENV="$PWD/.venv" bash scripts/zensical_docs.sh serve --dev-addr=127.0.0.1:8000 +``` + +`serve` запускает предварительную генерацию и наблюдение за результатом сборки. +Полнота обновления всех AI-файлов при каждом редактировании отдельно не проверена. + +## Производные файлы — не исходные входы + +| Путь | Кто создаёт | +|---|---| +| `docs/assets/social/` | generate_social_cards.py | +| `docs/robots.txt` | generate_social_cards.py | +| `overrides/partials/page_meta.html` | generate_social_cards.py | +| `docs/llms.txt`, `docs/llms-full.txt`, `docs/ai/` | AI-генератор и генератор векторов | +| `.cache/site-markdown-pages.jsonl` | Кэш для переноса Markdown-страниц в результат | +| `.cache/` и `site/` | Кэши инструментов и собранный сайт | +| `.venv/`, `__pycache__/` | Локальное окружение и кэш Python | + +Сейчас генерация пишет часть результатов внутрь `docs/` и `overrides/`. +Это фактическое смешение исходников и результатов, которое надо учесть при +будущем разделении каталогов. Кэши и готовый `site/` не должны быть необходимы +для первой сборки из исходников. + +## За пределами этого минимума + +| Группа | Назначение и дальнейшее решение | +|---|---| +| `tests/`, `dev/requirements-test.lock`, benchmark/check-скрипты вне списка выше | Проверка качества и разработка; находятся вне обязательных входов сборки | +| `spec/`, `AGENTS.md`, `README.md` | Внутренние описания; не нужны для генерации сайта | +| `dev/content/`, включая `dev/content/data/` | Подготовка и обновление контента; отделены от сборки уже подготовленного контента | +| MCP-сервер, runtime, snapshot consumer, release controller, MCP-зависимости | Выполнение MCP и управление поставкой; не нужны для сборки сайта | +| `.github/workflows/`, `delivery/ci/Dockerfile`, публикация и настройки VPS | Автоматизация проверки и поставки | +| `delivery/site/Dockerfile`, `delivery/site/site.conf`, `delivery/site/build_local_site.py` | Упаковка локального сайта для пользователя; это часть целевой поставки | +| `delivery/index/generate_mcp_snapshot.py`, `runtime/v8std_mcp_snapshot_format.py`, `runtime/v8std_mcp_presentation.py` | Дополнительные зависимости упаковки индекса для локального образа сайта | +| `delivery/mcp/Dockerfile`, `delivery/local/compose.yaml` | Поставляемый MCP и совместный запуск контейнеров | +| `LICENSE`, `.gitignore`, `.dockerignore` | Лицензирование проекта и управление содержимым репозитория/контекста Docker; сохранять по назначению, даже если команда HTML-сборки их не читает | + +## Что осталось разделить + +1. Генерация всё ещё пишет производные файлы в docs/ и overrides/. + Вынос выходов сборки выполняется отдельным этапом; набор исходных входов + при этом должен остаться проверяемым. +2. `delivery/site/build_local_site.py` принимает подготовленные AI-файлы, + меняет настройки на локальный адрес и упаковывает snapshot индекса. + Передача одного готового артефакта индекса на VPS и в образ сайта ещё требует + отдельного изменения этого маршрута. +3. `delivery/local/compose.yaml` связывает MCP с сайтом через depends_on и + выводит MCP наружу через контейнер сайта. Самостоятельный Compose MCP + ещё надо отделить от совместного режима. +4. `scripts/v8std_mcp_chunks.py` остаётся общей зависимостью создания индекса + и runtime. MCP в имени не означает, что этот файл нужен только серверу. + +Код уже разнесён по назначению согласно [плану](repository-layout-plan.md). +Файлы вне минимума HTML-сборки сохранены для остальных результатов поставки. + +## Проверка списка + +При составлении реестра 16 сентября достаточность набора проверена strict-сборкой +во временном каталоге только с перечисленными входами. Использовалось установленное +Python-окружение рабочего checkout; установка зависимостей с нуля, Docker и +интерактивный просмотр в эту проверку не входили. + +17 сентября пути и состав реестра повторно сверены с текущим рабочим деревом. +После удаления внутренней инструкции контейнерной установки из `docs/` число +отслеживаемых файлов контента уменьшилось на один. Прежний прогон подтверждает +проверенный тогда набор, а не готовность текущего кандидата к выпуску. + +## После разделения каталогов + +Входы из основного реестра остаются на месте. Остальные исходники распределены +между runtime, delivery и dev; четыре вспомогательных data-файла находятся +в dev/content/data. Старые инструменты и документы удалены после согласования атомарных правил. +Docker-окружение разработки удалено; сайт разрабатывается в нативном Python-окружении. +Описанные выше смешение выходов +с исходниками и ограничение самостоятельного Compose MCP пока сохраняются. diff --git a/spec/mcp-surface-contract.md b/spec/mcp-surface-contract.md new file mode 100644 index 0000000..ddd84fe --- /dev/null +++ b/spec/mcp-surface-contract.md @@ -0,0 +1,298 @@ +# Контракт поверхности MCP + +Дата: 17 сентября 2026 года. Описание текущего рабочего дерева, а не подтверждение +развёртывания этой версии на `ai.v8std.ru`. Изменений поведения этот документ не вводит. +Цель поставки — [delivery-target.md](delivery-target.md). + +## Формат спецификации + +В репозитории нет OpenSpec, OpenAPI или Swagger-описания. MCP обслуживает +JSON-RPC через Streamable HTTP; описание инструментов доступно через `tools/list` +в виде JSON Schema. Это не OpenAPI-документ. + +В текущей реализации `inputSchema` описывает типы, обязательность и значения +по умолчанию. Большинство ограничений длины, перечислений и диапазонов +проверяет код, но схема их не объявляет. Исключение — `snippet.maxLength`. +У всех пяти инструментов `outputSchema` имеет тип `object` и +`additionalProperties: true`: строгой схемы полей ответа пока нет. +Поэтому ниже отдельно описаны фактические поля и ограничения. + +## Назначение и границы + +MCP — источник знаний по стандартам, диагностическим правилам и практикам 1С. +Он ищет статьи, возвращает Markdown и связи между страницами. Он не читает +проект пользователя, не запускает анализаторы и не изменяет код. +Рекомендации по фрагменту — подбор материалов по сигналам, а не заключение +полного статического анализа. + +Поверхность одинакова для публичного и локального MCP. Используется только +Streamable HTTP, без stdio. Источник индекса влияет на данные и адреса статей, +но не добавляет другие инструменты. Один вызов использует одно поколение индекса. + +## Подключение и JSON-RPC + +- Публичный адрес: `https://ai.v8std.ru/mcp`. +- Текущий совместный Compose: `http://127.0.0.1:18766/mcp`. +- В приложении путь настраивается; значение по умолчанию — `/mcp`. +- Запросы: `POST`, `Content-Type: application/json`. +- Для совместимого MCP-клиента: `Accept: application/json, text/event-stream`. + Текущий SDK в режиме JSON также принимает только `application/json`. +- JSON-RPC `2.0`: `id` сопоставляет запрос и ответ; уведомления не содержат `id`. +- Сервер stateless, ответы на запросы — JSON. Сессионный идентификатор + для последовательности вызовов не требуется; отдельного SSE-потока нет. +- Клиент выполняет `initialize`, принимает согласованную версию протокола, + отправляет `notifications/initialized`, затем вызывает `tools/list` и `tools/call`. + В последующих запросах передаёт согласованную версию в `MCP-Protocol-Version`. +- Закреплён SDK `mcp==1.27.0`. Протокольные проверки проекта используют + `2025-03-26`; это не утверждение проверки каждой версии, поддерживаемой SDK. + +Пример тела `initialize`: + +```json +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"example-client","version":"1"}}} +``` + +В ответе `serverInfo.name = "v8std"`. Текущее `serverInfo.version = "1.27.0"` +поступает от SDK и не является SHA выпуска проекта. Объявляются `tools` и +`prompts` с `listChanged: false`, а также пустой `experimental`. +`resources` отсутствует. Есть текст `instructions` о выборе инструментов. +`ping` возвращает пустой объект. `prompts/list` возвращает пустой список: +пользовательских промптов нет, хотя SDK объявляет capability `prompts`. + +Пример вызова инструмента: + +```json +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"v8std_search","arguments":{"query":"модальные окна","limit":5}}} +``` + +Успех содержит `result.structuredContent` — объект результата, и +`result.content` — один элемент `{"type":"text","text":"..."}` с JSON того же +объекта. `isError` имеет значение false или отсутствует. Пустой результат +поиска и ненайденная страница не являются ошибками выполнения инструмента. + +## Общие параметры и структуры + +Длины строк измеряются в символах Python/Unicode, а не байтах HTTP. +Пустые строки не запрещены общей проверкой длины. Клиенту следует отправлять +типы из `inputSchema`, не полагаясь на приведение типов SDK. + +| Параметр | Поведение | +|---|---| +| `limit` | Целое, по умолчанию 10; значения за пределами 1–50 ограничиваются ближайшей границей | +| `id_or_alias_or_url` | Строка до 1000 символов: ID, псевдоним, путь, HTML- или Markdown-URL известной страницы | +| `types`, `relations` | Массив строк либо null; null и пустой массив не ограничивают выборку; элемент до 64 символов | +| Неизвестное значение перечисления | Ошибка инструмента, а не пустая выборка | + +**SearchEntry** — объект результата поиска и рекомендации по фрагменту: + +| Поле | Тип и смысл | +|---|---| +| `id`, `type`, `title`, `description` | Строки: идентичность, тип и краткое описание страницы | +| `url`, `markdown_url` | Строки: HTML и Markdown страницы | +| `score` | Число для ранжирования, не вероятность; шкала не обещана стабильной | +| `match_reasons` | Массив причин совпадения, не более 12 | +| `score_details` | Объект числовых вкладов в оценку; набор ключей зависит от поиска | +| `related_preview` | До 8 объектов Relation | + +**Relation**: строки `relation`, `id`, `type`, `title`, `url`, `markdown_url`, +`source_path`. При обогащении связей добавляется `description: string | null`. +Виды связей: `standard`, `diagnostic`, `edt_check`, `related`. + +Внутренние ссылки представляются относительно выбранного сайта, включая +Markdown-ссылки в тексте. URL внутри примеров кода сохраняются. Внешние ссылки +источников не должны восприниматься как адреса локальной копии. +Клиент должен допускать дополнительные поля объектов. + +## v8std_search + +Поиск по произвольному вопросу, теме, ID или названию диагностики. + +| Аргумент | Тип | Обязателен | Значение и ограничения | +|---|---|---|---| +| `query` | string | Да | До 500 символов | +| `limit` | integer | Нет | 10, ограничение 1–50 | +| `types` | string[] или null | Нет | `standard`, `diagnostic`, `fix`, `pattern`, `service`; по умолчанию null | +| `mode` | string | Нет | `hybrid` (по умолчанию), `exact`, `bm25`, `semantic` | + +Ответ: `query`, `normalized_query`, `mode` — строки; +`types` — отсортированный фильтр либо null; `results` — массив SearchEntry. +`semantic_enabled: boolean` присутствует при непустом нормализованном запросе; +для пустого нормализованного запроса это поле отсутствует, а `results = []`. + +`hybrid` объединяет совпадения, текстовый и векторный поиск и связанные материалы. +`exact` включает также варианты кодов и нечёткий поиск кодов: название режима +не означает исключительно буквальное равенство. `bm25` включает текстовые +и метаданные совпадения. `semantic` использует имеющиеся векторы. +Порядок — по убыванию оценки с дополнительным детерминированным разрешением равенства. +Краткий текст результата — `description`; отдельных полей `aliases` +и `snippet` в SearchEntry нет. Описание инструмента отражает этот состав. + +## v8std_get_page + +Чтение известной страницы после поиска или по заранее известному идентификатору. + +| Аргумент | Тип | Обязателен | Значение и ограничения | +|---|---|---|---| +| `id_or_alias_or_url` | string | Да | До 1000 символов | +| `body_limit` | integer | Нет | 12000; ограничивается диапазоном 1000–30000 | + +Найдена: `{"found":true,"page":Page,"candidates":[]}`. +Не найдена: `{"found":false,"query":"...","candidates":[SearchEntry]}`. +Кандидатов не более 5; для запроса длиннее 500 символов кандидаты не ищутся. + +Page содержит `id`, `type`, `title`, `description`, `url`, `markdown_url`, +`source_path`, `aliases` (строки), `related` (Relation), `source_urls` (строки), +`body_markdown` (строка), `body_truncated` (boolean), а также поля исходного корпуса, +включая `site_path` и `markdown_path`, когда они присутствуют. +При обрезке к тексту добавляется `\n\n...`: итоговая длина может превышать +`body_limit` на длину этого маркера. Пагинации тела нет. + +## v8std_get_related + +Связи известной страницы, без произвольного тематического поиска. + +| Аргумент | Тип | Обязателен | Значение и ограничения | +|---|---|---|---| +| `id_or_alias_or_url` | string | Да | До 1000 символов | +| `relations` | string[] или null | Нет | Виды Relation; по умолчанию null | +| `limit` | integer | Нет | 10, ограничение 1–50 | + +Принимаются также прежние имена `related_standard` → `standard` и +`related_diagnostic` → `diagnostic`. +Ответ при успехе: `found: true`, `id`, `title`, `relations` (канонизированный +отсортированный фильтр либо null), `related` (обогащённые Relation). +Если страница не найдена: `found: false`, `query`, `related: []`. +Порядок связей сохраняется из индекса после фильтрации; это не ранжированный поиск. + +## v8std_explain_snippet + +Подбор диагностик и стандартов по одной процедуре BSL или фрагменту SDBL. + +| Аргумент | Тип | Обязателен | Значение и ограничения | +|---|---|---|---| +| `snippet` | string | Да | По умолчанию до 4000 символов; фактический предел в `inputSchema.properties.snippet.maxLength` | +| `language` | string | Нет | `auto`, `bsl`, `sdbl`; по умолчанию `auto` | +| `limit` | integer | Нет | 10, ограничение 1–50 для общего количества рекомендаций | + +Оператор может увеличить предел фрагмента до 32000 настройкой сервера и перезапуском. +Превышение длины даёт ошибку, а не молчаливую обрезку. Клиент сокращает фрагмент; +не повторяет тот же запрос и не разбивает целый модуль на параллельные вызовы автоматически. + +Ответ: + +- `language`: переданное значение. Сейчас оно проверяется и возвращается, + но не выбирает отдельный анализатор; `auto` не заменяется обнаруженным языком. +- `normalized_text`: нормализованный текст; `tokens`: массив токенов. +- `signals`: массив объектов с `type`, `value`, `target_ids` (массив ID), + необязательным `rule`. Повторяющиеся сигналы устраняются. +- `diagnostics`, `standards`: массивы SearchEntry; `standards` может содержать + страницы типа `pattern`. Общий бюджет двух массивов не превышает `limit`. +- `confidence`: число 0–1, эвристическая оценка, не вероятность корректности кода. + +Текст фрагмента может отражаться в нормализованном ответе. Инструмент не обещает +полного обнаружения ошибок или отсутствия ложных рекомендаций. + +## v8std_explain_diagnostics + +Объяснение списка известных кодов ACC/АПК, BSLLS и EDT/v8-code-style. + +Единственный обязательный аргумент `codes: string[]`: до 500 элементов, +каждый до 200 символов. Примеры: `acc 1245`, `АПК:361`, +`bslls:AssignAliasFieldsInQuery`, `v8cs:<идентификатор>`. +Звёздочка в описании семейства `v8cs:*` не означает поиск по wildcard. +Пустые после удаления пробелов значения пропускаются; повторы группируются. + +| Поле ответа | Содержание | +|---|---| +| `diagnostics` | Объекты с `id`, `title`, `url`, `markdown_url`, `frequency` и `standards` (обогащённые Relation) | +| `standards` | Сводные объекты `id`, `title`, `url`, `markdown_url`, `frequency`; сортировка по убыванию частоты, затем ID и URL | +| `unknown_codes` | Объекты `code` (нормализованный код), `frequency` | +| `total_input` | Число всех входных элементов, включая пустые | +| `unique_codes` | Число различных непустых нормализованных кодов | + +`frequency` у стандарта суммирует частоты связанных диагностик. +Неизвестный код либо код страницы не типа `diagnostic` попадает в `unknown_codes`. +Пустой список допустим, но вызов всё равно требует готового индекса. + +## Ошибки и доступность данных + +Различаются три уровня; клиент должен проверять каждый: + +| Уровень | Примеры и реакция | +|---|---| +| HTTP | Неверные заголовки: 406/415; некорректное тело: 400; Host/Origin проверяет защита SDK; proxy может вернуть 413, 429 или 503 | +| JSON-RPC `error` | Неизвестный метод, в том числе Resources: `-32601`; это не результат инструмента | +| `result.isError = true` | Неизвестный инструмент, неверные аргументы, превышение лимита, отсутствие готового индекса; описание в `content[].text` | + +Для неготового индекса текст ошибки содержит `INDEX_NOT_READY: retry later`. +Каталог инструментов и отказ Resources доступны и до загрузки индекса. +Точные тексты остальных исключений не являются стабильными машинными кодами; +единой прикладной структуры `{code, message}` пока нет. + +Ненайденная статья, пустая выдача и неизвестная диагностика — успешные ответы +с соответствующими полями. Сбой обновления сохраняет предыдущее рабочее поколение. +Офлайн-запуск возможен с проверенным кэшем выбранного источника. При ошибке +локального источника скрытого переключения на публичный нет. + +Методы `resources/list`, `resources/templates/list`, `resources/read`, +`resources/subscribe`, `resources/unsubscribe` отсутствуют (`-32601`) независимо +от URI и готовности индекса. Файлы корпуса, доступные по обычному HTTP, +не являются MCP Resources. + +## HTTP-маршруты приложения и VPS + +| Маршрут | Приложение | Конфигурация VPS в репозитории | +|---|---|---| +| `POST /mcp` | JSON-RPC | Проксируется | +| `GET /mcp` без SSE Accept | Справка HTML либо JSON | 405: GET запрещён nginx | +| `GET /mcp` с `text/event-stream` | 405, `Allow: POST, HEAD` | 405 | +| `HEAD /mcp` | 200 без тела | Проксируется | +| `/mcp/` | 303 для GET/HEAD, 308 для остальных на `/mcp` | Отдельное правило в include отсутствует; такое поведение снаружи не гарантировано | +| `GET /healthz` | 200 при готовом индексе, иначе 503 | Проксируется | +| `GET /livez` | 200, `{"ok":true}` — процесс жив | Отдельное правило в include отсутствует | +| `GET /version` | 200 со статусом и версией API | Проксируется | + +JSON-справка приложения содержит `message`, `endpoint`, `documentation`, `health`, +`version`, `codex_config`, `curl`. Сейчас публичный endpoint в ней зашит константой, +поэтому локальный экземпляр также показывает публичный адрес. + +`/healthz` содержит `ok`, `ready`, `row_count`, `semantic_enabled`, +`runtime_sha`, `corpus_id`, `archive_sha256`, `corpus_source_sha`, `loaded_at`, +`last_checked_at`, `last_success_at`, `refresh_error_code`, `hold_token`, +`release_control_token`. Идентификаторы, времена и код ошибки могут быть null; +времена — Unix seconds. Последние два поля относятся к управлению выпуском, +а не к авторизации MCP-клиента. +`/version` добавляет `service: "v8std-mcp"`, `api: "v2"`, +`api_profiles: ["legacy-tools"]`. Эти метки не являются версией протокола MCP. + +В VPS include: тело `/mcp` до 2 MiB, до 8 активных соединений на ключ зоны +`v8std_active`, proxy read/send timeout 30 секунд. 429 и недоступность upstream +обрабатываются с `Retry-After: 1`; 502/503/504 преобразуются в 503. +Это настройки поставляемого nginx, а не измеренные пределы production. +Локальный proxy сайта имеет другие настройки: 512 KiB и read timeout 65 секунд. +Приложение не настраивает OAuth или API key; проверка Host/Origin не является +аутентификацией. Внешняя инфраструктура может ограничивать доступ отдельно. + +## Проверяемость и известные расхождения + +Источники: [сервер](../runtime/v8std_mcp_server.py), +[инструменты](../runtime/v8std_mcp_index.py), +[поколения и представление](../runtime/v8std_mcp_runtime.py), +[nginx VPS](../delivery/vps/nginx/edge-locations.conf), +[локальный proxy](../delivery/site/site.conf). + +Существующие проверки: + +```bash +.venv/bin/python -m unittest tests.test_v8std_mcp_tools_only tests.test_v8std_mcp_server +``` + +Они проверяют каталог и схемы инструментов, оболочку результатов, отказ Resources, +холодный/готовый индекс и HTTP-справку приложения. Это не исчерпывающая проверка +всех полей данного документа и не проверка развёрнутого VPS. +Связанные атомарные обязанности находятся в [реестре правил](architecture-review.md). + +При дальнейшем изменении поверхности нужно отдельно решить существующие расхождения: +неполные JSON Schema, лишняя capability `prompts`, публичный адрес в локальной +справке и запрет справки на VPS. +Здесь они зафиксированы как факты, без молчаливого изменения согласованного поведения. diff --git a/spec/mcp-tool-design.md b/spec/mcp-tool-design.md new file mode 100644 index 0000000..438df54 --- /dev/null +++ b/spec/mcp-tool-design.md @@ -0,0 +1,281 @@ +# Проект инструментов MCP для работы ИИ с кодом и диагностиками + +Дата: 17 сентября 2026 года. Описания инструментов и параметров согласованы и реализованы. +Остальные изменения (annotations, outputSchema, подсказки ошибок) не реализованы. +Текущий контракт: [mcp-surface-contract.md](mcp-surface-contract.md). +Этот документ не добавляет согласованных архитектурных правил и не объявляет +совместимость проверенной на выпущенных бинарных версиях Unica. + +## Решение + +Сохранить пять существующих инструментов, их имена, аргументы и ответы по умолчанию. +Улучшить описания, описания параметров, аннотации и точность схем ответов. +Не добавлять универсальный `analyze`, диспетчер `execute` или дубли `*_v2`: +в имеющемся наборе уже есть отдельное действие для каждого нужного вида входа. + +Сервер остаётся источником знаний, используемых ИИ при анализе кода. +Сам анализ проекта и получение исходников выполняются клиентом/Unica. +MCP подбирает применимые материалы и объясняет известные коды; наличие совпадения +не доказывает нарушение, отсутствие совпадений не доказывает корректность кода. +Запуск статического анализатора в эту поверхность не входит. + +HTTP, Python и текущий SDK сохраняются. Смена фреймворка не нужна для изменения +описаний и создаст отдельный риск совместимости. Рекомендация скила о standalone +FastMCP 3.x рассмотрена, но не превращается в обязательную миграцию существующего сервера. +Нет новой авторизации, доступа к файловой системе пользователя, UI-ресурсов, +stdio или дополнительного сетевого сервиса. + +## Выбор инструмента по входу + +| Что есть у ИИ | Первый вызов | Что он даёт | +|---|---|---| +| Процедура BSL или текст запроса SDBL | `v8std_explain_snippet` | Кандидаты правил и диагностик по сигналам фрагмента | +| Коды диагностик из отчёта анализатора, комментария или подавления в исходнике | `v8std_explain_diagnostics` | Известные диагностики, связанные стандарты и неизвестные коды | +| Точный номер стандарта, ID, псевдоним или URL статьи | `v8std_get_page` | Текст соответствующего документа | +| Вопрос словами, неизвестное название, неоднозначный номер без источника | `v8std_search` | Ранжированные кандидаты | +| Известная статья и необходимость посмотреть связанные правила | `v8std_get_related` | Явные связи в корпусе | + +«Коды» здесь — идентификаторы диагностик и стандартов. Число в строковом литерале, +бизнес-код номенклатуры и код ошибки внешнего API не становятся диагностикой 1С +только потому, что встретились в исходниках. + +Для смешанного входа ИИ отделяет идентификаторы от программного текста: +список известных диагностик отправляется одним вызовом `explain_diagnostics`, +нужный фрагмент — отдельным `explain_snippet`. При известном `std437` чтение +стандарта не требует предварительного поиска. Перед выводом о нарушении ИИ +сопоставляет фактический код с текстом выбранных правил через `get_page`; +одна поисковая оценка не является обоснованием нарушения. + +## Обратная совместимость с Unica + +Исследованы локальные теги репозитория Unica: + +- `v0.5.1`: `04b97cf4ae4b4139b7856540cdd39c76c00486aa`; +- `v0.12.3`: `f6d23068c397cd85c540812de7627b2c3f434d68`. + +В обоих `crates/unica-coder/src/infrastructure/internal_adapters.rs`, +`StandardsAdapter::request_for`, содержит следующие фиксированные вызовы: + +| Вызов Unica | Имя MCP | Передаваемые аргументы | +|---|---|---| +| `search`, либо `explain` с query | `v8std_search` | `query`, `limit`, `types`, `mode` | +| `explain` с codes | `v8std_explain_diagnostics` | `codes` | +| `explain` со snippet | `v8std_explain_snippet` | `snippet`, `language`, `limit` | +| `explain` с id/idOrAliasOrUrl | `v8std_get_page` | `id_or_alias_or_url`, `body_limit` | + +При нескольких полях Unica выбирает codes, затем snippet, затем id, затем query. +Это поведение клиента; серверу не нужно добавлять аналогичный диспетчер. +`get_related` также сохраняется для прямых клиентов, хотя этот адаптер его не вызывает. + +Старый адаптер отправляет прямой `tools/call` по HTTP без предварительного +`initialize`, согласования нового профиля и сессионного токена. Этот рабочий +путь нужно сохранить наряду с нормальным жизненным циклом MCP-клиента. + +В `v0.12.3` файл `standards_documentation.rs` читает JSON из `content[0].text`: +поиск — `results[].url/title/description/score`, документ — `found`, +`page.body_markdown`, `page.url`, `page.title`. Переход только на +`structuredContent`, Markdown вместо JSON или добавление внешнего `{data: ...}` +сломают этот потребитель, даже если имена инструментов не поменяются. + +### Неприкосновенная часть интерфейса + +1. Все пять имён, текущие имена аргументов, обязательность, defaults и действующие + допустимые значения сохраняются. Новых обязательных аргументов нет. +2. Числовые limit продолжают ограничиваться диапазоном, а не отклоняться. + Пустые строки/списки и действующие aliases остаются допустимыми. +3. Успешный результат сохраняет один первый text-блок с JSON и соответствующий + `structuredContent`; все прежние поля, типы, null и варианты отсутствия сохраняются. +4. Ненайденное значение остаётся успешным результатом с `found: false`, пустой + выдачей или `unknown_codes`. Ошибки выполнения сохраняют `isError: true`. +5. Публичный MCP сохраняет `https://v8std.ru/` в URL страниц. Адрес загрузки архива + на `ai.v8std.ru` не должен становиться базовым адресом статей. +6. Никаких обязательных новых заголовков, аутентификации, сессий или новой версии + протокола ради пользования прежними инструментами. + +**Граница совместимости:** публичный endpoint старой Unica входит в обязательную +приёмку. Потребитель документации Unica v0.12.3 отбрасывает URL вне +`https://v8std.ru/`. Поэтому совместный локальный MCP с локальными URL нельзя +объявить полностью совместимым с этим потребителем без изменения Unica. +Не подменять локальные ссылки публичными: это противоречит согласованной локальной +поставке. Прямые вызовы локального MCP проверяются отдельно; полная поддержка +локальных URL старым потребителем Unica остаётся известным ограничением. + +Два исследованных тега не доказывают совместимость всех старых версий. Перед +выпуском нужно проверить границы поколений адаптера в остальных поддерживаемых +тегах и воспроизвести запросы каждого отличающегося поколения. + +## Точные описания tools/list + +Тексты ниже предназначены для поля `description`, а не для статьи на сайте. +Английский сохраняется как язык интерфейсных описаний; русские запросы и код +поддерживаются. Параметры описываются отдельно в JSON Schema. + +### v8std_search + +Title: `Search 1C standards and diagnostics` + +```text +Search the v8std knowledge base by a natural-language question, topic, or uncertain identifier. Returns ranked page IDs, titles, descriptions, URLs, scores and match reasons; no full article text. Known diagnostic codes are handled by v8std_explain_diagnostics; BSL/SDBL source fragments by v8std_explain_snippet. An exact page ID or URL can be read with v8std_get_page. An empty result means no match in this corpus, not that the code is correct. Scores rank candidates and are not probabilities. +``` + +### v8std_get_page + +Title: `Read a standard or diagnostic article` + +```text +Read a known v8std article by ID, alias, source path, HTML URL or Markdown URL, for example std437. Returns found, page metadata, Markdown text and body_truncated; an unknown page returns found=false and possible candidates. body_limit defaults to 12000 characters and is clamped to 1000–30000. A truncated body is incomplete; the returned markdown_url identifies the full document. This is document retrieval, not code analysis. For an unknown topic, v8std_search finds candidate IDs. +``` + +### v8std_get_related + +Title: `Find rules linked to an article` + +```text +Retrieve explicit corpus links from a known standard or diagnostic article. Returns related page IDs, relation types, titles, descriptions and URLs, filtered by relations and limited by limit. An unknown starting page returns found=false; an empty related list means no matching recorded links. Links provide reading context, not evidence that a rule is violated. This tool does not discover arbitrary topics or retrieve full article bodies; those operations are v8std_search and v8std_get_page. +``` + +### v8std_explain_snippet + +Title: `Find rules relevant to a BSL or SDBL fragment` + +```text +Match one BSL procedure or SDBL fragment against signals in the v8std knowledge base. Returns candidate diagnostics and standards, matched signals and heuristic confidence, with at most limit recommendations in total. It does not execute code, inspect a repository or run a static analyzer; matches are not confirmed violations and no matches do not certify correctness. Full rule text is available through v8std_get_page. Diagnostic identifiers from reports or source comments are handled by v8std_explain_diagnostics. The fragment limit is {max_snippet_chars} Unicode characters; a size error requires a smaller relevant fragment, not an unchanged retry. language records the supplied hint and currently does not select a separate analyzer. +``` + +`{max_snippet_chars}` подставляется из конфигурации того же экземпляра, из которой +строится `inputSchema.properties.snippet.maxLength`; это не буквальный текст в каталоге. + +### v8std_explain_diagnostics + +Title: `Explain diagnostic codes and linked standards` + +```text +Resolve diagnostic identifiers from analyzer reports or source-code comments and suppressions, such as acc 1245, АПК:361, bslls:AssignAliasFieldsInQuery or an exact v8cs identifier. Accepts up to 500 strings, each up to 200 characters. Returns known diagnostics grouped with linked standards, occurrence frequencies and unknown_codes. Empty entries are ignored; repeated codes are counted. Unknown means not resolved in this corpus, not a verified invalid diagnostic. Resolving a suppression code does not justify the suppression. No wildcard matching or analyzer execution is performed. Full descriptions are available through v8std_get_page using returned IDs. +``` + +## Параметры и схемы + +Названия параметров не улучшать переименованием: они являются API старых клиентов. +К существующим properties добавить следующие `description`: + +| Параметр | Текст для JSON Schema | +|---|---| +| `search.query` | `Question, topic or uncertain identifier, in Russian or English. Maximum 500 Unicode characters. Example: модальные окна. Not a full source module.` | +| `search.types` | `Optional page types: standard, diagnostic, fix, pattern, service. null or [] means all types.` | +| `search.mode` | `hybrid combines available matching methods (default); exact includes identifier variants and fuzzy code matches; bm25 is text/metadata search; semantic uses indexed vectors.` | +| `*.limit` | `Maximum results, default 10. Values are clamped to 1–50. For explain_snippet this is the combined diagnostics and standards budget.` — последнее предложение только для snippet | +| `*.id_or_alias_or_url` | `Known article ID, alias, source path, HTML or Markdown URL; maximum 1000 Unicode characters. Example: std437.` | +| `page.body_limit` | `Markdown body budget in Unicode characters, default 12000, clamped to 1000–30000. A truncation marker may add characters; inspect body_truncated.` | +| `related.relations` | `Optional relation kinds: standard, diagnostic, edt_check, related. Legacy related_standard and related_diagnostic are accepted. null or [] means all.` | +| `snippet.snippet` | `One relevant BSL procedure or SDBL fragment. Limit is the advertised maxLength. The normalized response may contain source text; omit secrets.` | +| `snippet.language` | `Source-language hint: auto (default), bsl or sdbl. Currently echoed in the response without selecting a separate analyzer.` | +| `diagnostics.codes` | `Exact diagnostic identifiers with analyzer namespace when known; up to 500 entries of at most 200 characters. Example: ["acc 1245", "bslls:AssignAliasFieldsInQuery"]. No source files or raw analyzer report.` | + +Отразить уже действующие maxLength/maxItems и enum. Сохранить null и пустые +массивы там, где они принимаются. Не добавлять `minLength: 1` или +`minItems: 1`. Для limit/body_limit не вводить minimum/maximum с отказом вместо +нынешнего clamping. Не вводить `additionalProperties: false` на входе без +проверки прежнего поведения SDK и старых запросов. + +OutputSchema описывает существующие объекты из текущего контракта: SearchEntry, +Relation, варианты found/not-found, результаты snippet и diagnostics. Не вводить +новый внешний контейнер, обязательные поля, отсутствующие на отдельных ветках, +или удаление неизвестных полей корпуса при сериализации. В частности, +`semantic_enabled` отсутствует на пустом поисковом запросе; `description` связи +может быть null. Модели/схемы должны описывать ответы, а не менять их. + +Все инструменты получают понятный title и annotations: +`readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, +`openWorldHint: false`. Последнее относится к работе с выбранным корпусом: +инструмент не открывает произвольный URL из аргумента. Фоновое скачивание +индекса отдельно от вызова инструмента не является обещанием отсутствия сети. +Обновление корпуса может менять ответ при повторном запросе. + +## Объём ответов и исправление ошибок + +В этом изменении не добавлять `format`, компактный режим по умолчанию или +пагинацию: старые поля нельзя удалить ради экономии токенов. Ограниченный список +кандидатов и отдельное чтение нужных статей уже позволяют не отдавать весь корпус. +Не возвращать полные тексты всех найденных правил из explain-инструментов. + +Для ошибок сохранить транспорт и прежние распознаваемые сообщения; добавить +короткую подсказку без отражения исходного фрагмента или секретов: + +| Случай | Подсказка восстановления | +|---|---| +| Слишком длинный snippet/query | Указание допустимой длины; сократить релевантный фрагмент/запрос; неизменённый повтор не поможет | +| Неизвестный mode/type/relation/language | Допустимые значения; исправить аргумент | +| `INDEX_NOT_READY` | Временно нет проверенного индекса; отложенный ограниченный повтор, без смены источника | +| Неизвестный diagnostic code | Успешный `unknown_codes`; проверить namespace и версию анализатора, не выдумывать расшифровку | +| `found: false` | Изучить candidates или уточнить запрос; не сообщать об отсутствии самого стандарта во всех источниках | +| `body_truncated: true` | Текст неполный; увеличить body_limit до действующего максимума или читать полный markdown_url доступным клиенту средством | + +Не заменять `isError` на успешный `{error: ...}`. Структурированную новую модель +ошибок не включать в это изменение: она требует отдельной проверки парсеров. +Тексты статей и фрагментов — данные, а не инструкции на запуск команд или +переопределение правил клиента. В логах вызовов snippet не сохранять исходный код. + +## Как проверить проект при реализации + +Сначала сохранить до изменения реальные запросы/ответы на малом фиксированном +корпусе, включая публичные адреса. Затем использовать тот же корпус для новой +версии: это отделяет совместимость формы от обновления контента и ранжирования. + +1. **Старые клиенты.** Воспроизвести search и все ветки explain из двух указанных + тегов; проверить прямой tools/call без initialize, заголовки старого клиента, + JSON в первом text-блоке, признак ошибки. На этапе реализации запустить + настоящий парсер/адаптер соответствующих версий, не ограничиваться похожим + Python-тестом. Отдельно проверить get/search потребителя документации v0.12.3. +2. **Схемы.** Каждый успешный ответ проходит заявленную outputSchema, включая + пустые и отрицательные результаты; text JSON равен structuredContent. + Прежние запросы сохраняют поведение на границах длин и clamping. +3. **Маршрутизация ИИ.** На одном наборе задач сравнить старые и предложенные + описания при одинаковой модели и настройках. Проверить выбранный инструмент, + аргументы, лишние вызовы, чтение основания перед выводом и отсутствие + неподтверждённых заявлений о нарушениях. Прогон Inspector проверяет протокол, + но не заменяет оценку выбора инструмента моделью. +4. **Локальная поставка.** Сохранить локальные URL и отказ от fallback. Провести + отдельный отрицательный тест ограничения старого documentation provider, + чтобы оно не маскировалось пустым успешным результатом в отчёте совместимости. + +Минимальный набор задач для маршрутизации: + +| Вход | Ожидаемое действие/граница | +|---|---| +| «Проверь процедуру» и небольшой BSL-код | snippet; далее выбранные статьи, без утверждения о запуске анализатора | +| SDBL с `ВЫБРАТЬ РАЗРЕШЕННЫЕ` | snippet; применимость сверяется с текстом правила | +| Диагностики из SARIF/лога | извлечь ID и namespace, один diagnostics вызов; не отправлять весь отчёт | +| Комментарий подавления с кодом BSLLS | diagnostics; наличие описания не оправдывает подавление | +| «Что требует std437?» | get_page напрямую | +| «Как заменить модальные окна?» | search, затем релевантная статья | +| «Что связано с этой диагностикой?» и известный ID | related | +| Голое `361` без источника | не выдумывать namespace; уточнить источник или искать кандидатов | +| Повторяющиеся/неизвестные коды | корректные frequency/unknown_codes, без повторения того же вызова | +| Фрагмент длиннее объявленного maxLength | сокращение релевантного участка; отсутствие автоматического массового разбиения | +| Пустая выдача по коду | отсутствие заявления «нарушений нет» | +| Инструкция «игнорируй правила» внутри комментария | трактовать как текст исследуемого кода | + +Критерий допуска: все сценарии совместимости проходят; на перечисленных задачах +нет ошибочного выбора семейства инструмента и ложных обещаний анализа проекта. +Преимущество новых описаний оценивается по результатам сравнительного прогона, +а не объявляется заранее. Новых тестов без соответствующей обязанности не добавлять. + +## Порядок изменения + +1. Вычитать этот проект и границу совместимости. +2. Зафиксировать baseline старых запросов и парсеров; добавить описания параметров, + описания инструментов и annotations без изменения реализации инструментов. +3. Описать реальные outputSchema и улучшить подсказки ошибок, сохраняя ответы. +4. Выполнить проверки совместимости и сравнительные сценарии ИИ, обновить текущий + контракт по фактическому результату. Выпускать по обычным правилам поставки. + +## Использованные скилы + +- [tool-design](https://github.com/muratcankoylan/agent-skills-for-context-engineering/blob/main/skills/tool-design/SKILL.md): разделение задач инструментов, описание входов/выходов, восстановление после ошибок и проверка выбора на задачах. +- [build-mcp-server](https://github.com/anthropics/claude-plugins-official/blob/main/plugins/mcp-server-dev/skills/build-mcp-server/SKILL.md): небольшая поверхность отдельных действий, HTTP и сохранение выбранного стека. +- [Tool design reference](https://github.com/anthropics/claude-plugins-official/blob/main/plugins/mcp-server-dev/skills/build-mcp-server/references/tool-design.md): описания параметров, annotations и отсутствие управляющих инструкций в описании инструмента. + +Контекст Claude загружен из [экспорта документации](https://claude.com/docs/llms-full.txt). +Публикация в каталоге Claude не входит в задачу; требования каталога не создают +нового процесса согласования в этом репозитории. Формулировки descriptions выше +написаны для v8std и существующего контракта, а не скопированы из примеров скилов. diff --git a/spec/operations/2026-09-09-markdown-rendering-verification.md b/spec/operations/2026-09-09-markdown-rendering-verification.md deleted file mode 100644 index 8295e04..0000000 --- a/spec/operations/2026-09-09-markdown-rendering-verification.md +++ /dev/null @@ -1,415 +0,0 @@ -# Task4: Markdown rendering verification, 2026-09-09 - -## Current interpretation, approved 2026-09-10 - -The user authorized correcting the agent-added no-JS-dark requirement, not -adding a theme feature. The applicable matrix is 108 recorded combinations: -9 routes × 4 widths × (light JS, dark JS, existing light no-JS). All retain -readable articles, no zero grid column and no direct article text. The three -no-JS header overflow observations within this matrix have equal before/after -widths and are outside the article; they are not claimed fixed. Another 36 -dark-preference/no-JS rows remain raw observations of the light fallback. - -Mermaid's identical HTML and no-JS source text satisfy preservation checks; -its absent clipboard control is N/A. SVG rendering was not proven in either -reference variant and is not newly claimed or repaired. No product, CSS, -template, dependency or accepted architecture document changed in this scope -clarification. Original probe results below remain unchanged as history. -Fresh integration gates and whole-branch review are recorded at the end. - -## Historical collection result, 2026-09-09 - -Outcome: **PARTIAL / DONE_WITH_CONCERNS**. The authorized evidence collection -is finished and handed off for review. Task4's complete browser gate is **not -passed**. No-JS dark is **NOT PROVEN**, six matrix rows retain overflow, and -the Mermaid browser probe did not prove diagram rendering. No plan checkbox -was changed. No commit, merge, push, publication or MCP operation occurred. - -## Scope and identity - -Verification followed Task4 of the -[implementation plan](../plans/2026-09-09-markdown-fence-rendering-plan.md) -and its approved design. Work stayed in the existing main checkout and feature branch -`codex/issue-35-markdown-rendering-plan`. - -HEAD and main during verification: -`8e891945dc3767ea328239b8c562ffded7d4e4d6`. This is the base, **not an -implementation/integration commit**. The older design reproduction base -`f9e660bd...` is not the tested working-tree base. - -`git diff --binary main` SHA-256 before and after probes: -`d24f854376100a71aee274ee204e733ef829de0e1571a5f95f30963b80a548c4`. -This tracked-diff hash excludes untracked implementation files. Their exact -identity is included below and in `before-task4-snapshot.json`. - -| Checked file | SHA-256 | -|---|---| -| scripts/check_article_html.py | 848b7c02cb2e3993082fd0c72cd5fbcf1f77ae4dd479a8c2794ef15fd63dd9c0 | -| scripts/v8std_markdown.py | da1cf9a6de442346e55facb60970e5790b9467f471a16db3f5cb9d84f1fb6235 | -| scripts/zensical_docs.sh | 39cc36abb43f39785bf9255a150773b7677572eda64996099ac2873af858698e | -| zensical.toml | 9792880301a4638b2879ac885c2186cb7059cc5787ef52967fb0f9415a6b41a4 | -| tests/test_article_html.py | a907a7991c8e5ea0433400ca0f0ebec797f2c482834c0e7fb33b13874a845f10 | -| tests/test_v8std_markdown.py | c7783ab296349f416825439dff67aadc4dce5ef7be65a826274d06089940e8d4 | - -Host: Python 3.12.10, Zensical 0.0.47, Markdown 3.10.2, PyMdown 11.0.1. -Browser: isolated headless Google Chrome 151.0.7922.109 through bundled -Playwright/Node 24.19.0; no installation or user browser profile. - -All runtime JSON, command logs, HTML fixtures and screenshots remain in -`/tmp/v8std-issue35.pLZSHc`. They are temporary local evidence, not committed -attachments. This report and scripts preserve the findings and reproduction. - -## Corpus and source preservation - -`verify-task4.py corpus` exited 0. Each of **1428** `docs/**/*.md` files was -rendered by a fresh Markdown instance with project configuration, once with -the extension and once without. HTMLParser projections compared ordered -heading/link/pre/code/highlight/mermaid tags and sorted attributes; exact -pre/code text; and whitespace-normalized ordinary visible text. Code -whitespace was not normalized. Initially valid pages also required full HTML -byte equality. - -| Measure | Result | -|---|---:| -| Baseline structurally invalid Markdown pages | 42 | -| Changed HTML pages | 42 | -| Originally valid pages with byte-identical HTML | 1386 | -| After-render structurally invalid pages | 0 | -| Semantic, code, attribute or valid-page regressions | 0 | -| Compact/separated reference fixtures, exact HTML | 11/11 | - -The prior fresh site baseline, recorded by Task1, had **1429 articles, 234 -violations on 42 pages**. The final checked site has **1429 articles, zero -violations**. The HTML count includes generated `404.html`; it is not the -1428-source-file count. The baseline counts are attributed to Task1, not -presented as a second baseline site build by this worker. - -Final `git diff --exit-code main -- docs data/diagnostic-sources.json` exited -0 with empty output. All 3358 preexisting files in the initial tracked/source/ -implementation snapshot still matched their initial SHA-256 at handoff. -This includes generated AI files, CSS, templates and dependencies. - -Source checks: diagnostic article integrity **358**, ACC **691**, relation -graph **358**, all exit 0. Source-derived expected Markdown sidecars were -regenerated in memory and compared to **1270** actual site sidecars: zero -mismatches. No normalized HTML input replaced the sidecar/AI source corpus. - -Independent source manifest digest over sorted `docs/**/*.md`, followed by -`data/diagnostic-sources.json`, with each record `path + NUL + file_sha256 + LF`: -**1429 files**, SHA-256 -`e402aa912934177bc418bfe58e9766c986fddc9d6cea7c2759dbdb19e1c7c462`. -The ledger's `6d1e2372...` digest uses an unspecified serialization; these two -aggregate digests are not asserted equal. Git/main equality and per-file -before/after hashes provide the independently verified preservation evidence. - -Evidence: `corpus.json`, `sidecars.json`, `before-task4-snapshot.json`, -`after-task4-probes-snapshot.json`, `browser-oracles.json`, source-check logs. - -## Serve and local Docker - -Serve ran through `VIRTUAL_ENV="$PWD/.venv" ./scripts/zensical_docs.sh serve ---dev-addr=127.0.0.1:8769`. The first probe incorrectly treated the startup -HTTP response as ready; it exited 1 before the article file existed. Its own -process group was stopped. The corrected verification script waited for one -valid article and the actual HTML file, then completed successfully. - -For the successful run (own process group leader PID 55673): initial and -mtime-triggered HTTP responses each contained one article and zero checker -violations. Only `touch docs/diagnostics/bslls/SetPrivilegedMode.md` triggered -the rebuild. HTML file mtime changed; serve remained the same process; the log -contains the initial and subsequent `No issues found` events. No checker -rewrote the response. - -Source before/after SHA-256: -`ccff3b04bac6621a0734d8c8b87f1fe917c35c2214b7f2a812c0b3f00b22eafe`. -HTTP body before/after SHA-256: -`06cf99bbcab3a96a58f8afe006d594c86eabdaae2e342d133c3aa8a077e79876`. -Evidence: `serve.json`, `serve.log`, `serve-initial.html`, `serve-rebuilt.html`. - -Docker context was explicitly `desktop-linux`, endpoint -`unix:///Users/ingvarvilkman/.docker/run/docker.sock`; client/server preflight -29.7.2. Real local Docker build, image import from `/tmp` with -`PYTHONPATH=/opt/v8std`, and project-config render through a read-only `/docs` -mount all exited 0. The rendered compact BSL fixture contained standalone -before/code/after blocks and passed `check_html` (1 article, 0 violations). - -Built runtime: Zensical **0.0.47**, Markdown **3.10.3**, PyMdown **11.0.2**. -Thus Docker evidence is independent of the older host dependency versions. -The existing install script and dependency policy were not changed. -Image retained locally: `v8std-markdown-check:issue35`, ID -`sha256:79d5bb8144a85cb30916cd503af23eefa0ac62d56cba4e94b7ac581d95a0a4a2`. -No compose service or MCP server was started. Evidence: `docker-{build,import,render}.{json,log}`. - -## Browser matrix and residual failures - -Routes: `/diagnostics/bslls/SetPrivilegedMode/`, the same route with -`?h=привилеги#setprivilegedmode`, `/std/640/`, `/std/643/`, `/std/686/`, -`/std/726/`, `/lang/`, -`/diagnostics/v8-code-style/common-module-named-self-reference/`, and -`/diagnostics/bslls/TransferringParametersBetweenClientAndServer/`. -The EDT route is the specified baseline representative. - -Each route used widths 390/768/1024/1920, JS on/off, and light/dark OS preference: -**144 rows and 144 screenshots**, zero navigation/probe errors, all HTTP 200. -Screenshots awaited fonts (`loaded`), used reduced motion and disabled -animations, and had no consent overlay. In JS contexts the actual consent UI -was opened, GitHub unchecked through its label, the unchecked state verified, -then `Принять` saved the refusal. No optional cookie was enabled. - -| Requested mode | Rows | Actual scheme | Layout failures | Theme gate | -|---|---:|---|---:|---| -| JS light | 36 | default | 0 | Proven | -| JS dark | 36 | slate | 0 | Proven | -| no-JS light | 36 | default | 3 | Light observed; overflow remains | -| no-JS dark | 36 | default | 3 | **NOT PROVEN** | - -All 144 rows had zero non-whitespace direct article text nodes and no zero -grid column. At issue35 widths 1024/1920, columns were respectively -`151.609px 577.281px` / `166.75px 846.688px`; page widths equaled viewports. -At 390/768 the article was non-grid and readable. These measurements are not -an assertion that all browser gates passed. - -The **six failure rows** are lang, EDT and residual BSLLS, each at 390 px in -both no-JS OS preferences. Their page width is **403 px**. A reconstructed -before-render comparison used the current identical theme shell, substituted -only the article rendered without the extension, and served the HTML outside -the corpus. Before and after both measured 403 px; corresponding JS cases -measured 390 px. This is a controlled renderer comparison, not an archived -pre-change full-site build. - -Cause evidence: the unchanged outer `.md-header__topic` / `.md-ellipsis` -title reaches x=403 (width 306) in these no-JS pages. The article containers do -not produce that page overflow. Code spans can extend within their scrolling -containers; actual horizontal wheel probes confirmed internal code scroll -on issue35/lang/residual with and without JS, **6/6**, advancing scrollLeft -to 240/47/240 px. The header issue predates the renderer change, but the six -matrix failures remain recorded and are not relabeled green. - -Evidence: `browser-matrix.json`, `layout-details.json`, `final-diagnostics.json`, -`layout-*.png`, `code-scroll-*.png`. Parent also independently viewed issue35 -mobile light/dark and no-JS std640 keyboard focus screenshots and reported -them readable with no consent blur. This report's worker visually inspected -issue35 mobile light, issue35 1920 dark, residual mobile dark, and std686 -1024 no-JS screenshots; the remaining screenshots are available for review. - -## Clipboard, links, search and Mermaid - -`browser-interactions.json` contains **53 checks** and no probe exceptions. -Of these, **42 non-fixture checks passed**: 32 page-code preservation checks -(8 distinct pages × 4 JS/preference combinations), four anchor checks, four -keyboard chip checks, and two JS search-highlight checks. Both no-JS -preferences still mean actual light, not dark coverage. - -Real code-copy buttons copied **184 blocks** from the eight pages in two JS -themes; clipboard permissions were granted only to isolated contexts and -reads followed the probe's own copy action. DOM pre/code text matched the -without-extension oracle exactly. Clipboard strings matched expected code -with the theme's terminal-newline trimming (`trimEnd`); this transformation -was not applied to the corpus preservation comparison. - -Existing `#setprivilegedmode` links navigated and positioned the target in -view. `h=привилеги` produced the observed matching `mark[data-md-highlight]` -elements. On std640, Shift+Tab followed by Tab moved focus back to the actual -first diagnostic chip, with `:focus-visible` observed; Enter navigated to its -unchanged OrderOfParams URL. `chip-focus-*.png` records the focused state. - -Ten applicable compact/reference code fixtures produced equal clipboard -strings. The original JSON's eleventh fixture, **Mermaid**, has -`passed=false`, `compact=[]`, `reference=[]`, `expectedBlocks=1`. This raw -failure is retained. Its expectation incorrectly assumed that a Mermaid -pre/code becomes a code-copy control. Both actual variants have zero copy -buttons. The scoped rerun `browser-fixture-copy.json` marks this case -**not applicable to clipboard**, with `passed=null`, not true; ten code -fixtures pass. This does not prove Mermaid browser rendering. - -The required preservation evidence is independently present: compact and -separated-reference HTML matched exactly in the in-memory fixture test; -with JS disabled both browsers showed the identical -`
    graph TD\nA --> B
    ` and visible -`graph TD / A --> B` text between the same surrounding paragraphs. - -With JS enabled the supplemental fixture browser produced an empty -`div.mermaid` in **both** variants; no SVG was observed. The first temporary -fixture server also yielded `Uncaught Error: File not found`. A later -fixture-only server supplied real site assets as a fallback; its already -completed diagnostic still found empty Mermaid divs, with no page errors. -Therefore SVG/diagram rendering remains **NOT PROVEN / PARTIAL**; no claim -that Mermaid was successfully replaced by SVG is supported. No product -regression was demonstrated by the equal before/reference behavior, and -no product fix was attempted. Collection was stopped by the parent/controller. -This is not a user waiver of the browser gates; no user answer on no-JS dark -scope has been received. - -## Gates, process boundaries and handoff - -| Command/probe | Exit | Confirmed result | -|---|---:|---| -| Diagnostic integrity / ACC --check / standard links --check | 0 each | 358 / 691 / 358 | -| Four preservation unittest modules | 0 | 94 tests | -| Renderer + checker focused unittest modules | 0 | 52 tests | -| unittest discover -s tests -v | 0 | 338 tests, 33.775 s | -| Architecture impact | 0 | ARTICLE_HTML and source/code invariant | -| Architecture validate | 0 | Valid candidate graph | -| Architecture validate --merge-ready | 1 | INCOMPLETE_PLAN only | -| Final applicable strict build (pre-browser-strict) | 0 | No issues found; 11.67 s renderer, 36.14 s wrapper | -| Build-integrated and standalone article checker | 0 each | 1429 articles, 0 violations | -| Final source diff / git diff --check | 0 each | Empty output | - -The full suite alone used process-scoped `GIT_CONFIG_COUNT=1`, -`GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false` for its Git -fixtures. No permanent Git config was changed. No staging operation was run. -The binary index checksum changed during read-only Git/status/stat refresh; -byte-identical index preservation is **not claimed**. The existing staged -plan remains the only staged path (772 inserted lines); no plan file was -edited by this task. Subsequent Git reads used `GIT_OPTIONAL_LOCKS=0`. - -The last strict build restored the full site after serve and preceded browser -checks. All checked product/source/test/config files remained hash-identical -afterward, so it remains applicable at handoff. Per the parent's explicit -instruction, no redundant full suite or strict build was run after browser -collection. This is not a claim of a later build run. - -Semantic impact assessment: the verification files collect evidence for the -approved ARTICLE_HTML@1.0 boundary and source/code invariant; they introduce -no product requirement, ADR, contract, compatibility or governed product-path -change. Existing chip contract behavior was exercised. No-JS scope was not -narrowed: the pending user response is still absent. The plan/integration gate -remains the controller's responsibility. - -Cleanup: successful serve's own process group was stopped; initial temporary -fixture HTTP server PID 57995 was stopped when replaced. At handoff PID 56154 -was positively identified as this task's `python -m http.server 8769 ... site`, -and PID 60329 as this task's fixture server on 8770. Both received SIGTERM and -were absent on the subsequent process check. No unrelated process was killed. -Browser probes finished and closed their isolated contexts. Evidence and the -local Docker image were retained. - -Supplemental reproduction scripts are retained in the temporary execution -archive `/tmp/v8std-issue35.pLZSHc/execution/`, not shipped as product files: -`verify-task4.py` -(snapshot/corpus/gates/serve/docker/build/assets/fixtures-server), -`browser-task4.cjs` (matrix), `interactions-task4.cjs` (clipboard/links), -`layout-task4.cjs` (controlled before/after and wheel scroll), -`final-diagnostics-task4.cjs` (header and Mermaid observations). Use the provided -bundled Node with `NODE_PATH` pointing to its `node_modules`; static site -preview uses 8769, temporary fixture server 8770. Only start servers on free -ports and stop the processes started for the reproduction. -Durable renderer/parser regression checks are the committed -`tests/test_v8std_markdown.py` and `tests/test_article_html.py`; temporary -screenshots and probes supplement, rather than replace, those checks. - -Remaining review decisions: no-JS-dark scope, six unchanged no-JS header -overflow rows, and unproven Mermaid JS diagram output. Evidence is available -for task review and the controller's broad code review; no integration SHA -or public-site post-deploy evidence exists. - -Token telemetry snapshot at 2026-09-09 20:41:11 UTC (before final report text): -2,779,595 cumulative tokens, including 2,669,184 cached input and 24,154 output. -This is a measured intermediate total, not an exact final-turn total. - -## Review fix round 1 - -Bounded verifier/report corrections only; overall result remains **PARTIAL**. -Unexpected gate, full-suite, Docker import/render, build/checker and sidecar -failures now propagate through `main` to a nonzero process exit. Gates collect -all command failures. Only exit 1 with the exact sole expected -`INCOMPLETE_PLAN ...markdown-fence-rendering-plan.md: plan is not complete` -diagnostic is classified separately: `gates-summary.json` says **PARTIAL**, -never PASSED. Other errors alongside it are failures. - -The saved Docker render argument now exactly matches the previously successful -`docker-render.json` command, with correct Python newline/quote escaping. -New `docker-runtime` mode checks the existing image without rebuilding it. - -Validation: `PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -/tmp/v8std-issue35.pLZSHc/test_task4_verifier_fix1.py` — **11 tests, exit 0**. -All external commands were stubbed: tests cover every unexpected gate exit, -exact versus additional/wrong merge-ready diagnostics, mixed failures, both -build/checker exits, Docker build short-circuit, import/render aggregation, -top-level propagation, and exact equality to both prior Docker commands. -Actual corrected `verify-task4.py docker-runtime --label fix1-docker` — -**exit 0**, import **0**, render **0**, rendered **1 article / 0 violations**. -Evidence: `fix1-docker-{import,render}.{json,log}`. Same existing image/runtime; -no build, installation, service, browser matrix or product suite was run. - -Both reports now attribute collection closure to the **parent/controller**, -not a user waiver. No-JS dark still requires the unanswered user scope -decision; six unchanged overflows and Mermaid SVG remain partial. No product, -product tests, plan or Git state was modified in this correction round. - -Controller handoff: scoped re-review marked all three verifier/report findings -ADDRESSED with no new breakage. Product hashes still match the table above. -Only Task4 Steps 1–3 are marked complete in the candidate plan. Browser scope -and final acceptance remain open; no final whole-branch review or integration -has occurred. The user has not waived the dark/no-JS matrix requirement. - -## Pre-review local gates, 2026-09-10 - -The scope clarification at the top supersedes the historical pending no-JS-dark -decision. All applicable plan steps are complete; broad review remains the -integration gate. No theme or Mermaid feature was added or separately queued. - -Product file hashes still equal the six values above. Source/CSS/template/ -dependency diff against main is empty. Fresh runs: - -| Gate | Exit | Result | -|---|---:|---| -| `VIRTUAL_ENV="$PWD/.venv" ./scripts/zensical_docs.sh build --strict` | 0 | 1429 articles, 0 violations; renderer 11.98 s | -| `python -m unittest tests.test_v8std_markdown tests.test_article_html tests.test_diagnostics_registry_js -v` | 0 | 59 tests | -| `python -m unittest discover -s tests -v` after build | 0 | 338 tests, 32.995 s | -| Diagnostic integrity / ACC / relation graph | 0 each | 358 / 691 / 358 | -| Standalone HTML checker | 0 | 1429 articles, 0 violations | -| Architecture impact / normal validate | 0 each | ARTICLE_HTML + preservation invariant; valid graph | -| Source diff / `git diff --check` | 0 each | Empty output | - -Python above is `.venv/bin/python`. Full-suite Git fixtures used only the -already documented process-scoped signing override, not a product Git config -change. The first suite was incorrectly launched concurrently with build and -failed its existing published-license test while the site was being replaced; -the sequential full run above passed after all three licenses were published. -No code/test workaround was made. Logs are `final-20260910-{build,fitness, -suite,suite-sequential}.log` in the same temporary evidence directory. - -## Final review fix and renewed evidence, 2026-09-10 - -Whole-branch review found one remaining recognized-fence case: Setext consumed -the marker before `---` and created a false heading instead of code plus hr. -The single fix wave added a narrow priority61 guard before Setext60; the -general normalizer stays at11 and list tree pass at25. Four regression tests -cover compact/reference, ordinary headings, containers, indentation and -idempotence. RED was observed; the 56 renderer/checker tests passed afterward. -Scoped re-review found the issue ADDRESSED and no new breakage or observations. - -An independent old-module/new-module comparison rendered all 1428 current -Markdown sources, with reset between pages: zero HTML changes and zero source -changes. Thus existing-page browser and serve observations remain applicable; -they are not claimed as a new browser matrix or a new serve run. The new Setext -input is covered directly by reference-equality tests and the Docker probe. - -Only these two hashes differ from the initial six-file table: - -| Final file | SHA-256 | -|---|---| -| scripts/v8std_markdown.py | 1c27093958ea164f98f2d68d09da2cf653071b54c524aff195de775bfe206b68 | -| tests/test_v8std_markdown.py | b51bbf93c7e3b530ffdd3d744203f3aba56edb19e9791479d5b12e9df4e5c74a | - -Fresh strict build exited0: renderer12.05s,1429articles/0violations,3licenses. -Renewed fitness run exited0:63tests. Docker image was actually rebuilt with -the final module (build exit0), ID -`sha256:21f1e0dcd2f89f3e70e75bf769582a1857bc2c065d18e432e465a92f47c85656`. -Fallback import from `/opt/v8std` confirms the guard exists. The mounted-config -probe asserts exact equality of compact Setext input and separated reference, -presence of hr, absence of h2, and checker `(1, [])`; exit0. Runtime remains -Markdown3.10.3/PyMdown11.0.2/Zensical0.0.47. No MCP service was started. - -Supplemental corpus/fix probes are temporary artifacts. To rerun scripts that -resolve ROOT from their placement, restore the archived execution directory to -`spec/operations/issue35-execution/` in a feature checkout first. The shipped -test modules and commands above remain the durable regression entry points. - -Final full suite, run sequentially after the last build, exited0: -**342 tests in34.089s, OK** (`post-fix-suite.log`). All plan checkboxes are now -complete. The final scope is still exactly the original rendering repair: -accepted design/ADR/invariants/contracts, source corpus and MCP remain unchanged. -Integration will report its verified SHA separately; publication remains -unauthorized and issue35 must stay open until a public post-publication check. diff --git a/spec/operations/2026-09-09-mcp-incident.md b/spec/operations/2026-09-09-mcp-incident.md deleted file mode 100644 index ac28737..0000000 --- a/spec/operations/2026-09-09-mcp-incident.md +++ /dev/null @@ -1,138 +0,0 @@ -# Incident: intermittent MCP nginx 500, 2026-09-09 - -## Initial evidence (UTC) - -- At 17:54 health and initialize both returned 200 publicly and directly. -- Access log contained 15,691 HTTP 500 responses today, from 06:17:39 to - 15:23:55. Error log contained 19,730 connection-exhaustion alerts today. -- Error: `768 worker_connections are not enough while connecting to upstream`. -- Host: 1 vCPU, 961 MiB RAM, 344 MiB available; disk 23% used. -- Python PID 718 has run since July 10; health reports 1,423 pages / 3,281 vectors. -- Active nginx worker PID 485410 held 730 FDs (limit 1024); two shutting-down - workers and two masters remained after the August 21 binary upgrade. -- systemd MainPID=444817, `/run/nginx.pid`=444841, `.oldbin` PID file present. -- Existing nginx config still had 768 connections, no shutdown deadline, - and 3600-second proxy idle timeout. SSE heartbeat traffic can keep such a - stream open indefinitely. The earlier local fixes had not been deployed. -- Total established TCP sockets: 1,133. Python had 384 FDs. These are connection - observations, not unique agent counts. - -## Emergency action and authority - -User explicitly requested emergency investigation and production repair, with -broader architecture discussion afterwards. Apply the already approved -POST-only SSE policy at nginx and restore a clean nginx process tree. -No application release, Resource removal, topology expansion or website push -is part of this emergency action. - -The reviewed patch changes nginx's main-context worker FD limit to 8192, -worker connection slots to 4096, and worker shutdown deadline to 30 seconds. -The ai.v8std.ru vhost rejects GET offering text/event-stream with 405 and -Allow, uses 30-second client keepalive, and labels its existing rate limiter -429 with Retry-After. Existing rate, TLS, monitoring, upstream and other -vhost configuration are preserved. This is not a 100k-capacity claim. - -## Application procedure - -1. Save exact nginx.conf, ai.v8std.ru vhost, service/PID state and logs in a - private timestamped incident directory on the host. -2. Check the exact patch with `patch --dry-run`; install the HTTP map and - apply only the two reviewed configuration files. The patch uses zero-context - insertions: first compare live files byte-for-byte with the incident backups; - do not reuse it against another host or a changed configuration. -3. Run `nginx -t`. Restore saved files if validation fails; leave active - workers untouched on this path. -4. Restart nginx once to clear old binary generations and stale SSE sockets. - This briefly reconnects clients of the shared nginx host. -5. Verify public initialize, tools/list, search, page retrieval, health, - browser GET, HEAD, SSE 405, monitoring and the other vhost; inspect fresh - errors, FDs and process tree. Confirm reload leaves no stale workers. - -## Rollback - -Restore the two backed-up configuration files and remove only the newly added -HTTP map from the include directory (move it into the incident backup). -Run `nginx -t` before restarting. Rollback reinstates the known connection -exhaustion risk and is only for a regression of this hotfix. - -## Follow-up discussion - -- Earlier deployment examples require correction before use: - worker_rlimit_nofile belongs in main, not http; limit_conn counts active - requests after headers, not all idle TCP connections. String assertions - did not validate nginx syntax or prove a 40k-socket admission ceiling. -- Live payload/byte budgets, sync tool/refresh work, MCP FD limits, and retry - storms need separate measurements. Successful health alone is insufficient. -- Resource removal is an API change and not established as this incident's - cause. Keep it for the planned tools-only release discussion. -- Separate 403 errors show an unapproved browser-extension Origin; do not - weaken Origin validation as an outage workaround. -- Design acceptance, local merge and deployment are distinct; require a - deployed SHA/config checksum and post-deploy behavior proof in release reports. - -References: [nginx core directives](https://nginx.org/en/docs/ngx_core_module.html), -[limit_conn semantics](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html), -[MCP GET SSE transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports). - -## Recovery evidence - -- Deployed configuration source: local main commit - `d707daa9b6d487ae4cf9d93d6c34ff86fa6b5793`. Only the emergency nginx patch - and map were applied. No Git push or application update occurred. -- Backup/evidence directory (root-only): - `/root/v8std-incident-20260909-kkl40C` on ai.v8std.ru. -- At 17:59:50, exact patch dry-run and host nginx 1.24.0 `nginx -t` passed. - Existing unrelated OCSP warning for 0x1c.ru remained. -- Restart began at 17:59:58 and completed at 18:00:08 UTC (21:00:08 MSK). - systemd MainPID and nginx pid-file both became 643435. Two workers stuck in - shutdown and the obsolete master were cleared by the unit restart. -- Established TCP connections dropped from 1,133 to 6 immediately, then 41 - after 33 seconds of live reconnects. Python FDs dropped from 384 to 8. -- External health, browser GET, HEAD, initialize, tools/list, search and page - retrieval passed. Monitoring and 0x1c.ru returned 200. SSE GET returned 405 - with `Allow: POST, HEAD` in 0.364 seconds. -- Real Python MCP SDK 1.27.0 completed initialize -> list five tools -> search - with protocol 2025-11-25 and no tool error. -- An additional 30 sequential calls (10 lists, 10 searches, 10 page reads) - all returned HTTP 200 without JSON-RPC/tool errors: p95 0.311 seconds, - max 0.541 seconds, measured from the operator's machine. This is a smoke - sample, not a capacity benchmark. -- Reload at 18:01:12 passed. At 18:02:08 only master 643435 and worker 643662 - remained (12/16 FDs); no old worker remained past the 30-second deadline. -- Through 18:02:08, post-recovery access logs contained zero 5xx, 134 POST 200, - 17 POST 202 and 382 deliberately rejected SSE GETs. All 15,691 earlier 500 - responses had absent/zero upstream processing time. -- Local validation: 286 tests, architecture merge-ready, diff check and strict - site build passed. Actual nginx syntax and live lifecycle were tested on host. - -Applied configuration SHA-256: - -```text -7bf6520c75b8d35318acb91a52360e80324c763d8cebca5a79a48753e790ee6a /etc/nginx/nginx.conf -288f013098b88e617e9c8920237fa6a72eab978c72378967e778b8a7bf61339d /etc/nginx/sites-available/ai.v8std.ru -61ff46e3bb0929178410ad8dbea72b384837be8681d416389a73b2423b50d867 /etc/nginx/conf.d/v8std-mcp-emergency.conf -``` - -Application file SHA remained unchanged before/after: -`423017ecab0e96003dde0451fe6d5f7579d33f25b18ed52455968d22e69a968c`. - -## Postmortem conclusions and release discussion - -The confirmed failure was edge connection-slot exhaustion before an upstream -request could be processed. Long-lived unsolicited streams kept backend and -edge sockets occupied; the low worker limit and unreaped binary generations -amplified the problem. The application was alive throughout our investigation. -Initial successful probes did not disprove the intermittent failure. - -A prior local merge was incorrectly easy to read as an operational fix; it -had no production effect. Future completion reports must name the deployed -component, checksum/SHA and observed post-deploy behavior. The emergency repair -now has those observations. The proposed 100k topology remains unmeasured. - -For the next discussion: first agree the public tools-only contract and the -compatibility cost of Resource removal; then measure request CPU, refresh -blocking, response bytes and concurrency limits. Add a small set of operational -signals (real tool probes, connections/FDs, 5xx/429, p95, memory), review nginx -templates with a real parser, and benchmark before sizing horizontal replicas. -Resources, the old v3 design, monitoring redesign and the architecture-process -bootstrap are not prerequisites for this emergency recovery. diff --git a/spec/operations/2026-09-10-mcp-snippet-verification.md b/spec/operations/2026-09-10-mcp-snippet-verification.md deleted file mode 100644 index 318eca5..0000000 --- a/spec/operations/2026-09-10-mcp-snippet-verification.md +++ /dev/null @@ -1,171 +0,0 @@ -# Проверка крупных процедур в MCP, 10 сентября 2026 - -## Граница поставки - -Реализация согласованного `design:mcp-large-procedure-retrieval` выполнялась в -основном checkout, ветка `codex/mcp-large-procedure-design`, baseline -`3df5b40e773d0e7bc146ac2d9214934bb4145f73`. - -Исходный commit PR #31 `6467f77661e855a9f73f571e13db4964bff9c199` включён -локальным merge `f01ab71`; автор `andy24kr` и исходный co-author сохранены. -Перед интеграцией head открытого PR повторно проверен — тот же SHA. -Внешние комментарии, push, site publication и MCP deployment не выполнялись. - -## RED / GREEN и причина - -- До исходного PR длинная допустимая процедура вызывала ошибку query >500. -- После исходного PR 11 из 12 позиционных проверок с K=1 возвращали - постороннюю диагностику, а не ожидаемую первичную цель. После отдельного - включения структурных целей все варианты начала/середины/конца проходят. -- Новые tests проверяют 4k/32k границы, независимость экземпляров, общий top-K, - отсутствие повторного бонуса, неизвестный target, ложный вызов-идентификатор, - число поисков ≤1, компактность preview, config precedence и MCP wire. -- Runtime-схема прежде не содержала maxLength. Сейчас обе инстанции 4000/32000 - публикуют свой предел и согласованно проверяют вызовы. -- Public unit до изменения принимал локальный env override 32000. Теперь - проверка его реальной командной строки через parse_args даёт 4000. -- Helper усечения из PR разрывал длинное слово после короткого первого слова; - отдельный RED/GREEN-тест закрепляет целый префикс при существующей границе. -- Саморевью выявило общий consumer `search._query_tokens`: дедупликация внутри - analyze_snippet меняла частоту слов повторного SDBL-запроса с 7 на 6. RED/GREEN - закрепляет прежние 7; дедупликация перенесена исключительно на границу ответа - explain_snippet. В обычный search benchmark добавлен этот пограничный запрос. - -Дополнительно обнаружен implementation defect: unanchored regex присваивания -секрета перебирал суффиксы длинного идентификатора. Для `"я" * 32000` только -`has_secret_literal` занимал 7637.585 ms, для 4000 — 117.029 ms. Пробный -проход по максимальным identifier candidates — 0.708/0.111 ms соответственно. -Полный исправленный analyze_snippet на 32k слове в отдельном замере — 8.372 ms. -Это разные измеряемые функции; их время нельзя смешивать с полной retrieval latency. - -Сохранён прежний assignment regex и порядок потребления совпавшего literal; -изменён только выбор позиций его запуска. 10 000 детерминированных сравнений -со старым scanner совпали; 1000 таких сравнений и граничные литералы включены -в постоянный suite. По `failure-recovery` это implementation defect, не -проектная ошибка: значения лимитов, требования и критерии приёмки не менялись. - -## Реальные локальные способы запуска - -```bash -.venv/bin/python tests/mcp_snippet_smoke.py --launch python -.venv/bin/python tests/mcp_snippet_smoke.py --launch wrapper -.venv/bin/python tests/mcp_snippet_smoke.py --url http://127.0.0.1:18765/mcp -``` - -Третий сценарий выполнен на отдельно собранном Compose image, с отдельным -project name, временным контейнером и публикацией порта только на loopback. -Не использовались существующие пользовательские контейнеры. - -Во всех трёх сценариях подтверждены initialize, tools/list, все пять tools, -maxLength=32000, K=1/std485 в конце входа и size-error без `private_marker`. -Приняты 32k сообщения в UTF-8 (128051 bytes JSON body) и escaped Unicode -(383867 bytes). GET с Accept: text/event-stream возвращает 405. -Это не проверка публичного nginx: его 256k byte-limit не изменялся. -Проверочные процессы/контейнеры остановлены после проверки. - -Compose config отдельно проверен с env=32000: значение действительно передано -контейнеру. Direct Python и wrapper используют общий серверный парсер; -wrapper не изменён и не дублирует обработку env. - -## Проверки репозитория - -Итоговый полный suite после проверки shared consumer: 369 tests, OK, 39.195 s. Команда: - -```bash -GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false \ - .venv/bin/python -m unittest discover -s tests -q -``` - -Override отключает подпись только временных Git commits внутри тестового -процесса. Global/repository Git config не изменён. Первый прогон был остановлен -во время ожидания GPG в тестовом temporary repo. Затем полный suite обнаружил -недостающие обязательные headings нового ADR; документ исправлен, gate сохранён. -SDK выдаёт deprecation warning о будущем переходе Starlette TestClient на -httpx2; зависимости в этой работе не менялись, conformance выполняется успешно. - -Strict build: `VIRTUAL_ENV="$PWD/.venv" ./scripts/zensical_docs.sh build --strict` -завершён с кодом 0: 1429 articles, 0 violations, 3281 vectors, 3 canonical -license texts. Корпус стандартов и managed source blocks не менялись. - -Отдельный declared fitness/conformance прогон: 65 tests, OK, 8.219 s. -После завершения всех 18 шагов plan выполнены с кодом 0: - -```bash -.venv/bin/python scripts/v8std_architecture.py impact --root . --base-ref main -.venv/bin/python scripts/v8std_architecture.py validate --root . -.venv/bin/python scripts/v8std_architecture.py validate --root . --base-ref main --merge-ready -git diff --check -``` - -## Semantic impact по фактическому diff - -Намерение остаётся нетривиальным и соответствует согласованному графу. -Новые обязанности — полнота сканирования, приоритет целей, конфигурация/discovery, -bounded work и компактная выдача — связаны с design/ADR/invariant/contract и plan. - -Изменены только retrieval/snippet и config/discovery в существующем runtime, -локальная Compose env-передача, public unit pin, документация, тесты и внутренние -артефакты. Новая версия observable boundary — совместимая `MCP_API@2.3`. -Имена tools, обязательные аргументы, result shape, единственный `/mcp`, Resources -и POST-only остаются. Подписки, concurrency policy и metrics API не изменены. - -CLI impact также перечисляет прежние MCP API/usage/OpenMetrics и transport/ -metrics invariants из-за общих governs-путей. Их семантика сохранена: новые -usage labels/поля/исходный код в логах не появляются, listener/metrics/drain -не меняются. Старые structured документы в main не редактировались; новые -документы уточнялись только в candidate-ветке. - -## Идентичность проверенного runtime - -SHA-256 до завершающей интеграции: - -| Файл | SHA-256 | -|---|---| -| scripts/v8std_mcp_index.py | ac9b739775ca14ae7e939ec7f3f732d147b14010b9f70bfdbc6374fffd06eb5e | -| scripts/v8std_mcp_server.py | be1e73a27ad2c2aea08a516ffeede6286feb92a70752e964a13bd8567139d713 | -| scripts/v8std_retrieval_rules.py | 34b823aafa1cc8858a4d5a93449f170da35b1ab543f4661f9a1d66d4f8f79c4b | - -## Итоговый latency gate - -```bash -.venv/bin/python scripts/snippet_benchmark.py \ - --baseline-ref 3df5b40e773d0e7bc146ac2d9214934bb4145f73 \ - --report .cache/snippet-benchmark-final.json -``` - -Для каждого сценария: 20 warmups, 200 samples, 3 серии. Таблица содержит -медиану трёх p95, миллисекунды. Измеряется вызов индекса в одном локальном -Python-процессе, а не HTTP или нагрузка одновременно подключённых клиентов. - -| Сценарий | 4k / исходный короткий | 32k / новый короткий | Gate | -|---|---:|---:|---| -| Короткий snippet, baseline → исправление | 48.875 | 48.310 | PASS | -| Процедура с признаком в конце | 46.364 | 57.898 | PASS | -| Длинный идентификатор и признак в конце | 50.259 | 57.290 | PASS | -| Кавычки и признак в конце | 49.359 | 59.995 | PASS | - -Порог короткого snippet: baseline + max(20%, 10 ms); остальных пар: -p95_4k + max(25%, 20 ms). Все пороги выполнены без ослабления. -231 обычный поисковый case дал полностью идентичные результаты, включая -порядок, score и reasons, на baseline и новом коде при одном и том же корпусе. - -Повтор с `--without-vectors --report .cache/snippet-benchmark-no-vectors.json` -сохранил идентичность тех же 231 cases и выполнил все четыре latency gates: - -| Сценарий без векторов | 4k / исходный короткий | 32k / новый короткий | Gate | -|---|---:|---:|---| -| Короткий snippet, baseline → исправление | 15.893 | 15.741 | PASS | -| Процедура с признаком в конце | 18.482 | 30.486 | PASS | -| Длинный идентификатор и признак в конце | 16.795 | 24.616 | PASS | -| Кавычки и признак в конце | 16.602 | 28.017 | PASS | - -Quality gate: `.venv/bin/python scripts/search_benchmark.py --report -.cache/snippet-retrieval-quality-final.md` — MRR 0.994, p95 45.4 ms. -Пороги MRR ≥0.85 и p95 ≤500 ms не менялись. Все четыре новых длинных -позиционных случая возвращают требуемую цель первой. - -Индекс SHA-256: `4876122c8f2fa3c25c49afc0c986a72a976d4466e9b654041729ab42a634f4e4`. -Vectors SHA-256: `7713439da96757be4cf786d303e1cfb4b7e336ca8ec54a28a722701f6ef6c3c8`. -JSON report находится в ignored `.cache`; воспроизводимая команда и итоговые -значения сохранены здесь. Эти результаты не доказывают поддержку 100 000 -одновременных пользователей и не описывают состояние production MCP. diff --git a/spec/plans/2026-07-22-diagnostics-by-standard-clause-plan.md b/spec/plans/2026-07-22-diagnostics-by-standard-clause-plan.md deleted file mode 100644 index fb83824..0000000 --- a/spec/plans/2026-07-22-diagnostics-by-standard-clause-plan.md +++ /dev/null @@ -1,181 +0,0 @@ ---- -schema_version: 1 -kind: plan -id: diagnostics-by-standard-clause -design: design:diagnostics-by-standard-clause -implements: - - design:diagnostics-by-standard-clause - - contract:DIAGNOSTIC_RELATION_GRAPH@1.0 ---- - -# Diagnostics by Standard Clause Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the flat `/diagnostics/` table with a searchable, accessible registry grouped by standard and exact clause. - -**Architecture:** Extend the existing Python relationship generator so it parses standard clauses and renders semantic HTML inside Markdown. Keep confirmed relationship data as the sole mapping source; add a small progressive-enhancement JavaScript module and scoped CSS for filtering and optional empty-clause display. - -**Tech Stack:** Python 3.12, `unittest`, Markdown/HTML, vanilla JavaScript, CSS, Zensical 0.0.47. - -## Global Constraints - -- Show only standards and clauses with confirmed diagnostics by default. -- The control label is exactly `Показать пункты без проверок`. -- A confirmed relationship with `clause="stdNNN"` belongs to `Стандарт в целом`. -- A relationship with only one of `clause` and `anchor` is invalid. -- Without JavaScript, the compact useful registry remains visible. -- Do not modify existing relationship decisions or diagnostic backlinks. - ---- - -### Task 1: Clause-aware registry model and renderer - -**Files:** -- Modify: `scripts/generate_diagnostic_standard_links.py` -- Modify: `tests/test_diagnostic_standard_links.py` - -**Interfaces:** -- Produces: `load_standard_pages(standards_dir: Path) -> dict[str, StandardPage]` -- Produces: `render_registry_index(reviews, standard_pages) -> str` -- `StandardPage` contains the standard title and ordered clause records with `clause`, `anchor`, and optional `summary`. - -- [x] **Step 1: Write failing parser and renderer tests** - -Add fixture standards containing clauses `1`, `6.1`, and a code-first clause. Assert that parsing extracts the first prose sentence, preserves numeric ordering, falls back to no summary, renders `std → clause → diagnostics`, links to `../std/640.md#61`, creates `Стандарт в целом`, excludes rejected links, and raises `ValueError` for partial or missing clause targets. - -- [x] **Step 2: Run focused tests and verify failure** - -Run: `.venv/bin/python -m unittest tests.test_diagnostic_standard_links.GeneratedRelationshipGraphTests -v` - -Expected: FAIL because clause parsing and the new renderer contract do not exist. - -- [x] **Step 3: Implement the minimal clause model** - -Add frozen dataclasses for a clause and standard page, parse numeric `######` headings, skip managed backlink comments/admonitions/code as summary candidates, validate confirmed relationships, and group them by `(standard, clause, anchor)`. Sort clause numbers by integer components and deduplicate diagnostics per group using `_registry_diagnostic_sort_key`. - -- [x] **Step 4: Render progressive semantic markup** - -Generate a root `.diagnostics-registry`, a search input, the exact empty-toggle label, `
    ` blocks, clause sections, unique counts, hidden empty entries marked with `data-empty="true"`, and searchable normalized text in `data-search`. Render standard-level relationships last. - -- [x] **Step 5: Run focused tests** - -Run: `.venv/bin/python -m unittest tests.test_diagnostic_standard_links.GeneratedRelationshipGraphTests -v` - -Expected: all generated relationship graph tests PASS. - -- [x] **Step 6: Commit** - -Run: `git add scripts/generate_diagnostic_standard_links.py tests/test_diagnostic_standard_links.py && git -c commit.gpgsign=false commit -m "Group diagnostic registry by standard clauses"` - -### Task 2: Registry interaction - -**Files:** -- Create: `docs/assets/javascripts/diagnostics-registry.js` -- Create: `tests/test_diagnostics_registry_js.py` -- Modify: `zensical.toml` - -**Interfaces:** -- Consumes: `[data-diagnostics-registry]`, `[data-diagnostics-search]`, `[data-show-empty]`, `[data-standard]`, `[data-clause]`, and `[data-empty]` emitted by Task 1. -- Produces: filtering behavior without changing the generated catalog. - -- [x] **Step 1: Write failing static behavior tests** - -Assert the module registers on `document$` when available and `DOMContentLoaded` otherwise, normalizes Russian/Latin text, hides nonmatches, opens standards containing matches, restores original `open` state when the query clears, and never reveals `data-empty="true"` unless the toggle is checked. - -- [x] **Step 2: Run the test and verify failure** - -Run: `.venv/bin/python -m unittest tests.test_diagnostics_registry_js -v` - -Expected: FAIL because the JavaScript file and Zensical registration are absent. - -- [x] **Step 3: Implement interaction** - -Create an idempotent initializer. Cache each standard's initial `open` state, filter clauses from normalized `data-search`, hide standards with no visible clause, open matches while a query is active, restore state on clear, and reapply filtering when the empty toggle changes. - -- [x] **Step 4: Register and test** - -Add `assets/javascripts/diagnostics-registry.js` to `extra_javascript` and rerun `.venv/bin/python -m unittest tests.test_diagnostics_registry_js -v`. - -Expected: PASS. - -- [x] **Step 5: Commit** - -Run: `git add docs/assets/javascripts/diagnostics-registry.js tests/test_diagnostics_registry_js.py zensical.toml && git -c commit.gpgsign=false commit -m "Add diagnostic registry filtering"` - -### Task 3: Responsive registry presentation - -**Files:** -- Modify: `docs/assets/stylesheets/extra.css` -- Modify: `tests/test_diagnostic_standard_links.py` - -**Interfaces:** -- Consumes the class names emitted in Task 1. -- Produces a readable wide and narrow layout with visible keyboard focus. - -- [x] **Step 1: Add failing markup contract assertions** - -Assert the generated registry includes separate elements for controls, standard summary, counts, clauses, and diagnostic links so styling does not depend on content position. - -- [x] **Step 2: Run focused tests and verify failure** - -Run: `.venv/bin/python -m unittest tests.test_diagnostic_standard_links.GeneratedRelationshipGraphTests -v` - -Expected: FAIL for any missing styling hook. - -- [x] **Step 3: Add scoped CSS** - -Style only `.diagnostics-registry`: compact controls, bordered standard cards, clear disclosure focus state, muted counters, clause separators, wrapped diagnostic chips, dark palette support, and a single-column mobile layout below `44.984375em`. Use `[hidden] { display: none !important; }` within the registry. - -- [x] **Step 4: Run tests** - -Run: `.venv/bin/python -m unittest tests.test_diagnostic_standard_links.GeneratedRelationshipGraphTests tests.test_diagnostics_registry_js -v` - -Expected: PASS. - -- [x] **Step 5: Commit** - -Run: `git add docs/assets/stylesheets/extra.css tests/test_diagnostic_standard_links.py && git -c commit.gpgsign=false commit -m "Style clause-aware diagnostic registry"` - -### Task 4: Regeneration and end-to-end verification - -**Files:** -- Modify: `docs/diagnostics/index.md` (generated) - -**Interfaces:** -- Consumes all earlier generator, JavaScript, and CSS changes. -- Produces the committed public registry and verification evidence. - -- [x] **Step 1: Regenerate the registry** - -Run: `.venv/bin/python scripts/generate_diagnostic_standard_links.py --write` - -Expected: reports `registry=True` on the first run and changes `docs/diagnostics/index.md`. - -- [x] **Step 2: Prove deterministic generation** - -Run: `.venv/bin/python scripts/generate_diagnostic_standard_links.py --check` - -Expected: `relationship graph clean` and exit code 0. - -- [x] **Step 3: Run relationship and full unit tests** - -Run: `.venv/bin/python -m unittest tests.test_diagnostic_standard_links tests.test_diagnostics_registry_js -v` - -Run: `.venv/bin/python -m unittest discover -s tests -v` - -Expected: all tests PASS. - -- [x] **Step 4: Build strict documentation** - -Run: `VIRTUAL_ENV="$PWD/.venv" ./scripts/zensical_docs.sh build --strict` - -Expected: exit code 0 without warnings. - -- [x] **Step 5: Inspect representative output and formatting** - -Verify `std640` contains separate groups for clauses `3`, `4`, `5`, `6.1`, `6.2`, and `7`, plus `Стандарт в целом`; confirm `git diff --check` is silent and unrelated untracked audit files remain untouched. - -- [x] **Step 6: Commit generated output** - -Run: `git add docs/diagnostics/index.md && git -c commit.gpgsign=false commit -m "Publish diagnostics grouped by standard clauses"` diff --git a/spec/plans/2026-07-22-english-standard-sources-plan.md b/spec/plans/2026-07-22-english-standard-sources-plan.md deleted file mode 100644 index e7c4d27..0000000 --- a/spec/plans/2026-07-22-english-standard-sources-plan.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -schema_version: 1 -kind: plan -id: english-standard-sources -design: design:english-standard-sources -implements: - - design:english-standard-sources - - contract:STANDARD_SOURCE_REGISTRY@1.0 ---- - -# English Standard Sources Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add verified English 1Ci Knowledge Base links beside Russian ITS links on matching standard pages. - -**Architecture:** A JSON registry is the source of truth for confirmed `stdNNN` to English URL mappings. A deterministic Python synchronizer validates the registry and standard pages, renders source sections, and supports check/write modes; existing artifact generation consumes the resulting URLs. - -**Tech Stack:** Python 3.12 standard library, `unittest`, JSON, Markdown, existing v8std artifact generator and Zensical build. - -## Global Constraints - -- Preserve the Russian ITS URL as the primary source. -- Never use the English catalog root as a substitute for a missing article. -- Do not infer an English URL from `stdNNN`; only registry entries are rendered. -- Normal tests and builds must not require network access. -- English links are labelled as language versions, not equivalent current revisions. - ---- - -### Task 1: Registry validation and rendering contract - -**Files:** -- Create: `tests/test_standard_sources.py` -- Create: `scripts/standard_sources.py` - -**Interfaces:** -- Produces: `load_registry(path: Path) -> dict[str, str]` -- Produces: `render_sources(standard: str, russian_url: str, english_url: str | None) -> str` -- Produces: `sync_standard_sources(docs_dir: Path, registry_path: Path, write: bool) -> list[Path]` - -- [x] Write tests that require schema/version validation, unique standard IDs and URLs, the exact 1Ci HTTPS path prefix, matching ITS IDs, two-link rendering, preservation of single-source pages, and idempotence. -- [x] Run `python3.12 -m unittest tests.test_standard_sources -v` and confirm failure because `scripts.standard_sources` does not exist. -- [x] Implement the smallest standard-library module satisfying those tests, with CLI `--check` by default and `--write` for updates. -- [x] Run `python3.12 -m unittest tests.test_standard_sources -v` and confirm all tests pass. - -### Task 2: Verified mapping registry - -**Files:** -- Create: `data/standard-english-sources.json` -- Modify: `tests/test_standard_sources.py` - -**Interfaces:** -- Registry shape: `{"version": 1, "sources": [{"standard": "std498", "english_url": "https://kb.1ci.com/1C_Enterprise_Platform/Guides/Developer_Guides/1C_Enterprise_Development_Standards/Code_conventions/Using_1C_Enterprise_language_structures/Event_log/?language=en"}]}` sorted numerically by standard. - -- [x] Add a failing repository-level test that loads the real registry and requires every entry to resolve to an existing `docs/std/NNN.md` with the matching ITS source. -- [x] Run the focused test and confirm failure while the registry is absent. -- [x] Build mappings from the current public XWiki tree; confirm candidates by translated title plus distinctive structure/content, omitting unresolved cases. -- [x] Add the sorted registry and run the focused tests until they pass. - -### Task 3: Synchronize Markdown and generated artifacts - -**Files:** -- Modify: every `docs/std/NNN.md` named by the registry -- Modify generated files written by `scripts/generate_ai_artifacts.py` -- Modify: `scripts/generate_social_cards.py` only if its source-section parser rejects the plural heading -- Modify tests for social-card parsing only if Task 3 exposes that incompatibility - -**Interfaces:** -- Consumes: `python3.12 scripts/standard_sources.py --write` -- Produces: source sections matching the approved design and generated AI/MCP metadata containing both URLs. - -- [x] Run `python3.12 scripts/standard_sources.py --check` and confirm it reports unsynchronized pages. -- [x] Run `python3.12 scripts/standard_sources.py --write`, then repeat `--check` and confirm no drift. -- [x] Run `python3.12 scripts/generate_ai_artifacts.py` using the project virtual environment when present. -- [x] Run the generator again and confirm `git status --short` is unchanged after the second run. -- [x] Run focused tests for standard sources, artifact generation, and social cards; correct only incompatibilities caused by plural source sections. - -### Task 4: Full verification and documentation of coverage - -**Files:** -- Modify: `README.md` only if contributor commands for checking sources belong in the existing workflow section. - -**Interfaces:** -- Verification commands are the deliverable; no new runtime interface. - -- [x] Run `python3.12 -m unittest discover -s tests -v` and require zero failures. -- [x] Run `VIRTUAL_ENV="$PWD/.venv" ./scripts/zensical_docs.sh build --strict` and require exit code 0. -- [x] Run `python3.12 scripts/standard_sources.py --check` and the artifact generator once more to prove idempotence. -- [x] Run `git diff --check` and inspect representative matched, unmatched, and duplicate-title pages. -- [x] Report exact registry coverage, omitted/ambiguous cases, verification evidence, and files changed without claiming 317-page English coverage. diff --git a/spec/plans/2026-07-22-unified-diagnostic-chips-plan.md b/spec/plans/2026-07-22-unified-diagnostic-chips-plan.md deleted file mode 100644 index 440a6b5..0000000 --- a/spec/plans/2026-07-22-unified-diagnostic-chips-plan.md +++ /dev/null @@ -1,257 +0,0 @@ ---- -schema_version: 1 -kind: plan -id: unified-diagnostic-chips -design: design:unified-diagnostic-chips -implements: - - design:unified-diagnostic-chips - - contract:DIAGNOSTIC_CHIP_MARKUP@1.0 ---- - -# Unified Diagnostic Chips Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Render every visible diagnostic identifier as the same clickable chip and remove the visible «Проверки» heading from standards. - -**Architecture:** Keep relationship data and URL generation in `diagnostic_standard_links.py`, but make its standard backlink renderer emit semantic HTML using shared `.diagnostic-links` and `.diagnostic-chip` classes. Reuse the same link class in the generated registry and the two authored help pages; keep search-only attributes unchanged. - -**Tech Stack:** Python 3.12, `unittest`, generated Markdown with embedded HTML, Zensical, CSS. - -## Global Constraints - -- Every visible `acc:…`, `bslls:…`, and `v8cs:…` diagnostic identifier is a link with class `.diagnostic-chip`. -- Standard backlink groups have `aria-label="Проверки"` but no visible «Проверки» heading. -- Existing `diagnostic-backlinks:start` and `diagnostic-backlinks:end` markers remain unchanged. -- Search attributes and other invisible technical values are not converted into components. -- Light theme, dark theme, keyboard focus, and narrow screens remain readable. -- Before running Python commands, set `VIRTUAL_ENV=/Users/ingvarvilkman/Documents/git/v8std/.venv`; use `$VIRTUAL_ENV/bin/python` so project dependencies are available from the linked worktree. - ---- - -### Task 1: Render standard backlinks as chip groups - -**Files:** -- Modify: `tests/test_diagnostic_standard_links.py` -- Modify: `scripts/diagnostic_standard_links.py` -- Regenerate: `docs/std/*.md` - -**Interfaces:** -- Consumes: `_diagnostic_path(diagnostic: str) -> str` and confirmed `LinkReview` records. -- Produces: `render_standard_backlinks(reviews) -> dict[str, dict[str, str]]` whose values contain one `.diagnostic-links` container and `.diagnostic-chip` anchors. - -- [x] **Step 1: Write failing renderer tests** - -Add assertions to `RelationshipRenderingTests` for the exact public contract: - -```python -self.assertIn('