Compare commits
10 Commits
92a331f149
...
d7099bf80d
| Author | SHA1 | Date | |
|---|---|---|---|
| d7099bf80d | |||
| 00040e5ce4 | |||
| ad4bd3ec72 | |||
| d99d57eaf9 | |||
| ef2a94a3d0 | |||
| dcaac78cda | |||
| 39c8c4ab18 | |||
| c1d5d06f6d | |||
| 9d7c4f38f3 | |||
| 1ca47dce13 |
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: onec-sql-extension-adapter
|
||||
description: Develop, diagnose, or test this repository's SQL-only 1C adapter, especially extension saved-state preparation and public code writes. Use only inside the adapter repository; do not use it for ordinary client projects that consume the adapter API.
|
||||
---
|
||||
|
||||
# 1C SQL Extension Adapter Development
|
||||
|
||||
Use this project-local skill while changing or diagnosing the adapter itself. It protects the boundary between a simple client API and the adapter's private SQL implementation.
|
||||
|
||||
## Two contracts — never mix them
|
||||
|
||||
### Client / consuming-project contract
|
||||
|
||||
Callers use only public high-level operations and business selectors: `base_id`, extension name, object reference/name, requested change, and normal confirmation scope. They may read or write through the adapter, but they must not know or supply SQL table names, module references, stream indexes, file keys, hashes, containers, or serialization details.
|
||||
|
||||
An absent row in `ConfigCASSave` is not a caller problem. A caller must never be told to create it, save an extension again, initialize Configurator, or discover a technical route merely because the adapter failed to prepare saved state.
|
||||
|
||||
### Adapter-development contract
|
||||
|
||||
The adapter owns all SQL state preparation. For an extension write it must, from public selectors, resolve the exact extension layer, determine the writable module, create missing `ConfigCASSave` rows through the already proven copy path, write, reread, and report the result. Its implementation may inspect SQL evidence, but must never invent a container, mapping, payload, or reverse codec.
|
||||
|
||||
## Mandatory investigation workflow
|
||||
|
||||
1. Reproduce using the same public request that a client uses. Preserve its request ID and timings.
|
||||
2. Confirm the target through public read/search APIs: object owner, extension origin, module ordinal, and the exact old text. Do not infer from names alone.
|
||||
3. Inspect active and saved state independently. Classify each component as present-and-matching, absent, mismatched, ambiguous, or unreadable.
|
||||
4. Select the storage-key layout from live evidence in that base and extension family. A successful base-configuration `ConfigSave` path does not prove an extension `ConfigCASSave` path; equally, one canonical descriptor layout does not license renaming hash-keyed rows in another layout. A hash-keyed active extension root may itself decode to the proven logical file map; only then may it be mapped to canonical saved-state names.
|
||||
5. If the project has already proven the copy codec for the selected layout, use it for an absent component. Do not discard that proof merely because a new generic resolver expects a different descriptor or `__configinfo`. If a genuinely new layout is encountered, return a protocol-specific blocked result and develop it; never turn that gap into a Configurator instruction for the client.
|
||||
6. Test the exact public route with `plan`, then a controlled `apply_and_rollback` on `upo_test`. Verify readback and cleanup. Only then enable `apply_and_verify` for the route.
|
||||
|
||||
Read [the protocol and test reference](references/protocol-and-test-matrix.md) before altering saved-state preparation or interpreting its errors.
|
||||
|
||||
## Error taxonomy
|
||||
|
||||
| Result | Meaning | Required next step |
|
||||
|---|---|---|
|
||||
| `public_write_route_unresolved` | Public-to-internal resolver did not identify one safe route. | Repair the resolver using live evidence; do not ask the caller for coordinates. |
|
||||
| `extension_saved_state_prepare_protocol_unproven` | A needed saved-state part is missing and the reverse codec has not been proven for this layout. | Adapter protocol development and fixture testing. |
|
||||
| Transport closure / timeout | Request lifecycle or deployment interruption. | Correlate REST and MCP audit events before diagnosing SQL. |
|
||||
| `applied: true` | SQL reread matched the requested write. | Do not claim Configurator acceptance or activation without human confirmation. |
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
- Keep extension scope exact. Same GUID/name in another extension is not permission to write there.
|
||||
- Treat diagnostic SQL and raw file keys as adapter-private evidence, never as public API inputs.
|
||||
- Log request ID, public selectors, resolver phase, safe state classification, duration, result, and sanitized failure details. Do not log secrets or raw content unnecessarily.
|
||||
- Do not automate, emulate, or require Configurator. A human confirmation is the only evidence of Configurator visibility/activation.
|
||||
- `upo_test` is the sole default mutation target. Treat `upo` as read-only unless the user explicitly authorizes a write.
|
||||
- Update the API contract and runbook whenever public behavior, error meaning, or test coverage changes.
|
||||
|
||||
## Known dead ends in this project
|
||||
|
||||
- **Do not require a Configurator "initialisation" or another save because a target `ConfigCASSave` row is absent.** Missing rows are the normal first-write case handled inside the adapter.
|
||||
- **Do not replace the proven `ConfigCAS → ConfigCASSave` copy route with an unconditional canonical-name map.** A live hash key is not itself a logical filename. Canonical names are permitted only after decoding the selected extension root and proving its exact `O` / `O.0` SHA-1 map and extension GUID. For that evidenced family, copy `root → E__configinfo`, `O → E__O`, and `O.0 → E__O.0` atomically; otherwise return `extension_saved_state_prepare_protocol_unproven`.
|
||||
- **Do not copy active extension hash keys into `ConfigCASSave` under the same hash names and call it verified.** In `upo_test/фс_Отчеты` this passed SQL reread but Configurator continued to select `ConfigCAS`. SQL readback alone is not working-copy evidence.
|
||||
- **Do not send an object-module canonical `.0` path back through generic metadata path decoding when a public object selector or concrete stream already resolved it.** It is a BSL container, not a metadata tree path; route it directly through the extension saved-state resolver.
|
||||
- **Do not let a plan-only resolver redefine the write protocol.** `plan` may say preparation is needed; apply must execute the proven preparation route after its normal gate.
|
||||
- **Do not make a client retry with a module reference or storage coordinates.** That only hides the adapter regression and breaks every consuming project.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "1C SQL Adapter Development"
|
||||
short_description: "Safe development of 1C SQL extension writes"
|
||||
default_prompt: "Use $onec-sql-extension-adapter to diagnose or extend the adapter safely."
|
||||
@@ -0,0 +1,36 @@
|
||||
# Extension saved-state protocol and test matrix
|
||||
|
||||
## Evidence threshold
|
||||
|
||||
A writable extension path is proven only when all of the following are evidenced in a live test base:
|
||||
|
||||
1. The public selector resolves to exactly one extension-owned object and module.
|
||||
2. The active representation and extension layer are identified without cross-layer ambiguity.
|
||||
3. The corresponding saved-state mapping is observed, including how an absent part is prepared.
|
||||
4. The exact inverse codec is exercised on a controlled target.
|
||||
5. Readback proves the requested text replacement.
|
||||
6. `apply_and_rollback` restores the edited stream and removes every adapter-created overlay item.
|
||||
7. Before enabling a new layout generally, a human confirms that Configurator displays the saved-state change.
|
||||
|
||||
Never infer step 3 from an unrelated base object, a different extension, a filename convention, or an assumed `__configinfo` record. Raw saved-state keys may be hash-based or layout-specific. A hash key may be converted to a canonical saved-state name only when the selected active root itself has been decoded and contains the exact logical `O` / `O.0` to SHA-1 mapping. This is evidence for the three-file mapping, not a naming convention.
|
||||
|
||||
## Minimum regression matrix
|
||||
|
||||
| Case | What it proves |
|
||||
|---|---|
|
||||
| Base configuration saved write | Base `Config` to `ConfigSave`; not extension behavior. |
|
||||
| Extension with an existing saved module | Read/write codec for an existing extension saved entry. |
|
||||
| Extension module absent from saved state | Internal automatic preparation of a missing entry. |
|
||||
| Hash-keyed active extension root that decodes to a logical file map | Create exactly `root → E__configinfo`, `O → E__O`, `O.0 → E__O.0`; preserve root service atoms verbatim. |
|
||||
| Opaque hash-keyed extension layout | Return `extension_saved_state_prepare_protocol_unproven`; never insert same-name hash copies. |
|
||||
| Canonical descriptor overlay, if evidenced | Use its paired-container codec only for that observed layout. |
|
||||
| Extension with no usable saved overlay | Correct blocked result until a codec is proven; no caller workaround. |
|
||||
| Duplicate-looking object in another extension | Exact extension isolation. |
|
||||
| `apply_and_rollback` | Write, reread, rollback, and cleanup. |
|
||||
| Docker replacement during a long call | Graceful request lifecycle and audit correlation. |
|
||||
|
||||
## Safe public acceptance test
|
||||
|
||||
The test request contains only public fields: base ID, extension name, object ref/name, module ordinal or unambiguous module selector, `old`, `new`, mode, and normal repository-coordination scope. It must not contain `ConfigCASSave`, `module_ref`, stream index, file key, table name, SQL text, or a serialization payload.
|
||||
|
||||
Success means `applied: true` and a subsequent public search/read no longer finds `old` in the selected module. This proves SQL readback only. Configurator visibility requires separate human confirmation; activation/save remains outside this SQL-only adapter.
|
||||
@@ -16,11 +16,30 @@
|
||||
- For training/download containers on `docker-gpu`, sync the current repo into `Z:\LLM\model-chat-app` first. These containers should read code from the synced app directory, not directly from `Z:\codex\LLM`.
|
||||
- Do not store credentials, tokens, model secrets, or host passwords in repositories or project files.
|
||||
|
||||
## 1C SQL adapter deployment
|
||||
|
||||
- Deploy the production/external 1C REST SQL adapter and `adapter-1c-mcp` on `docker.cin.su` (`192.168.200.85`), where Codex connects to `http://docker.cin.su:8021/mcp`. `test-docker` (`docker-test.cin.su`, `192.168.200.61`) is a separate staging host; do not substitute it for the external MCP endpoint unless the user explicitly requests staging. Do not deploy either service on `docker-gpu.cin.su`.
|
||||
- The REST host port is configurable through `ADAPTER_1C_HOST_PORT`; use the same port in `ONEC_ADAPTER_URL` for MCP and agent services.
|
||||
- The adapter SQL-base administration page is `http://docker.cin.su:<ADAPTER_1C_HOST_PORT>/admin` (default: `http://docker.cin.su:8011/admin`). Use it to add or update live base connections; do not commit their SQL credentials or generated runtime configuration.
|
||||
- Before deployment, inspect the selected port and container names. Do not stop, remove, recreate, or otherwise alter unrelated running containers.
|
||||
- Keep live SQL connection settings in a non-committed deployment `.env` or mounted runtime configuration file.
|
||||
|
||||
## `upo_test` mutation scope
|
||||
|
||||
- `upo_test` is an isolated test infobase. The user has authorized full read/write adapter checks there, including controlled SQL saved-state changes and rollback.
|
||||
- This authorization does not turn an adapter-side marker into a native repository lock. When repository coordination is enabled, keep the configured request/confirmation scope and never claim automatic lock verification in the SQL-only adapter version.
|
||||
|
||||
## 1C adapter execution boundary
|
||||
|
||||
- **Primary correctness rule:** the adapter never invents 1C metadata, payload fields, SQL routes, containers, signatures, or object relationships. It decodes only structures evidenced in live 1C SQL storage and encodes only through the corresponding proven reverse codec.
|
||||
- If decoding is incomplete, a route is ambiguous, or a reverse encoding has not been proven, return an explicit `unresolved`/`unsupported`/`protocol_incomplete` result. Never substitute a plausible-looking structure or write guessed bytes.
|
||||
- **Rule for every agent using the adapter:** never invent database structure, 1C objects, modules, forms, fields, SQL joins, BSL fragments, or target files. Before proposing or applying a change, obtain the exact object/module/field and current text or payload from the live base through the adapter. Treat any absent, ambiguous, or unverified item as unknown; report it and stop that branch rather than filling it in from names, conventions, or prior experience.
|
||||
- The deployed 1C adapter works **only through SQL**. Its reads, saved-state preparation, and permitted writes use SQL tables such as `ConfigSave` and `ConfigCASSave`.
|
||||
- A human operates Configurator in the adapter's normal workflow. The adapter must not start, automate, emulate, or require Designer/Configurator; do not propose or implement a Designer bridge unless the user explicitly changes this rule.
|
||||
- A successful SQL write proves only SQL readback. Never claim that a change is visible, accepted, saved, activated, or validated by Configurator unless the human separately confirms it.
|
||||
- For SQL structures whose complete Configurator protocol is not proven (including `__configinfo`), return an explicit unsupported/protocol-incomplete result. Do not fabricate containers, signatures, or activation evidence.
|
||||
- These limits apply to the adapter product, not to its development. While creating, diagnosing, or testing the adapter, Codex may use any necessary authorised tools and evidence sources, including Configurator observation, test infobases, SQL, files, logs, and external research. Do not transfer that development capability into the adapter's runtime contract without explicit user approval.
|
||||
|
||||
## Test-system security profile
|
||||
|
||||
- This project currently runs as an isolated test system; use the minimum security profile unless the user explicitly requests production hardening.
|
||||
|
||||
@@ -26,10 +26,10 @@ docs/runbooks/adapter-1c-mcp.md
|
||||
core/deploy/docker/adapter-1c-mcp/compose.yaml
|
||||
```
|
||||
|
||||
1C REST adapter on GPU host:
|
||||
1C REST SQL adapter on `docker.cin.su`:
|
||||
|
||||
```text
|
||||
core/deploy/docker-gpu/adapter-1c/compose.yaml
|
||||
core/deploy/docker/adapter-1c/compose.yaml
|
||||
```
|
||||
|
||||
1C agent service (подпроект):
|
||||
@@ -40,6 +40,7 @@ docs/runbooks/1c-agent.md
|
||||
```
|
||||
|
||||
The current container is read-first and route-index backed. It serves
|
||||
`http://docker-gpu.cin.su:8011`, keeps the route index in the
|
||||
`http://docker.cin.su:8011` by default (the host port is configurable through
|
||||
`ADAPTER_1C_HOST_PORT`), keeps the route index in the
|
||||
`adapter-1c_adapter-1c-data` Docker volume, and is used by
|
||||
`adapter-1c-mcp` through `ONEC_ADAPTER_URL`.
|
||||
|
||||
@@ -5,3 +5,24 @@
|
||||
Здесь будут находиться compose-файлы, env-шаблоны и инструкции для запуска GPU-сервисов.
|
||||
|
||||
Секреты должны передаваться через окружение, секрет-хранилище или настройки хоста, но не через git.
|
||||
|
||||
Для 1C-адаптера `adapter-1c-audit` запускается рядом с REST-сервисом и раз в
|
||||
15 минут записывает безопасную сводку журнала в том `adapter-1c-data`:
|
||||
`/data/adapter-audit-reports/latest.json`. Период задаётся
|
||||
`ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS`; журнал не содержит BSL-текстов,
|
||||
SQL-полезной нагрузки или секретов.
|
||||
|
||||
Безопасное обновление полного 1C-стека выполняется из корня репозитория:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\deploy_1c_adapter_stack.ps1 -SkipVerify
|
||||
```
|
||||
|
||||
Этот каталог предназначен только для GPU-нагрузок. SQL-адаптер 1С и MCP
|
||||
разворачиваются на `docker.cin.su` из `core/deploy/docker/adapter-1c/` и
|
||||
`core/deploy/docker/adapter-1c-mcp/`. После обновления проверьте REST
|
||||
`http://docker.cin.su:8011/health?base_id=upo_test` и MCP
|
||||
`http://docker.cin.su:8021/health`.
|
||||
|
||||
CPU-only endpoint эмбеддингов для актуального поиска по коду 1С находится в
|
||||
`embeddings/`; он разворачивается на GPU-хосте, но намеренно не резервирует GPU.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Legacy location. Deploy the REST SQL adapter from
|
||||
# core/deploy/docker/adapter-1c/compose.yaml to docker.cin.su.
|
||||
name: adapter-1c
|
||||
|
||||
services:
|
||||
@@ -8,6 +10,7 @@ services:
|
||||
image: ${ADAPTER_1C_IMAGE:-adapter-1c-rest:latest}
|
||||
container_name: ${ADAPTER_1C_CONTAINER_NAME:-adapter-1c-rest}
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 5m
|
||||
ports:
|
||||
- "${ADAPTER_1C_HOST_PORT:-8011}:8011"
|
||||
volumes:
|
||||
@@ -19,6 +22,20 @@ services:
|
||||
ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN: ${ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN:-true}
|
||||
ONEC_SQL_BASES_JSON: ${ONEC_SQL_BASES_JSON:-}
|
||||
ONEC_SQL_BASES_JSON_FILE: ${ONEC_SQL_BASES_JSON_FILE:-/data/onec-sql-bases.json}
|
||||
ONEC_ADAPTER_CACHE_DB: ${ONEC_ADAPTER_CACHE_DB:-/data/adapter-cache.sqlite}
|
||||
ONEC_ADAPTER_STATE_DB: ${ONEC_ADAPTER_STATE_DB:-/data/adapter-cache.sqlite}
|
||||
ONEC_ADAPTER_JOB_STORE: ${ONEC_ADAPTER_JOB_STORE:-/data/adapter-jobs.json}
|
||||
ONEC_REPOSITORY_STATE_FILE: ${ONEC_REPOSITORY_STATE_FILE:-/data/onec-repository-locks.json}
|
||||
ONEC_ADAPTER_BACKUP_DIR: ${ONEC_ADAPTER_BACKUP_DIR:-/data/adapter-apply-backups}
|
||||
ONEC_CONFIGURATION_ACTIVATION_STATE_FILE: ${ONEC_CONFIGURATION_ACTIVATION_STATE_FILE:-/data/onec-configuration-activation-requests.json}
|
||||
ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS: ${ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS:-1800}
|
||||
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS: ${ONEC_ADAPTER_JOB_TIMEOUT_SECONDS:-240}
|
||||
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS: ${ONEC_ADAPTER_FULL_TIMEOUT_SECONDS:-600}
|
||||
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS: ${ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS:-180}
|
||||
ONEC_ADAPTER_JOB_PROCESS_ISOLATION: ${ONEC_ADAPTER_JOB_PROCESS_ISOLATION:-true}
|
||||
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB: ${ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB:-0}
|
||||
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS: ${ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS:-0}
|
||||
ONEC_ADAPTER_DEBUG_DIAGNOSTICS: ${ONEC_ADAPTER_DEBUG_DIAGNOSTICS:-false}
|
||||
ONEC_INFOBASE_USER_ADMIN_BASES_JSON: ${ONEC_INFOBASE_USER_ADMIN_BASES_JSON:-}
|
||||
ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE: ${ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE:-/data/onec-infobase-user-admin.json}
|
||||
ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST: ${ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST:-}
|
||||
@@ -28,5 +45,21 @@ services:
|
||||
ONEC_REPOSITORY_REQUEST_TTL_SECONDS: ${ONEC_REPOSITORY_REQUEST_TTL_SECONDS:-86400}
|
||||
ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS: ${ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS:-7200}
|
||||
|
||||
adapter-1c-audit:
|
||||
image: ${ADAPTER_1C_IMAGE:-adapter-1c-rest:latest}
|
||||
container_name: ${ADAPTER_1C_AUDIT_CONTAINER_NAME:-adapter-1c-audit}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- adapter-1c-rest
|
||||
volumes:
|
||||
- adapter-1c-data:/data
|
||||
environment:
|
||||
ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS: ${ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS:-900}
|
||||
command: >-
|
||||
sh -c 'mkdir -p /data/adapter-audit-reports;
|
||||
while true; do python /app/analyze_audit.py --log /data/adapter-audit.jsonl > /data/adapter-audit-reports/latest.json.tmp
|
||||
&& mv /data/adapter-audit-reports/latest.json.tmp /data/adapter-audit-reports/latest.json;
|
||||
sleep "$${ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS}"; done'
|
||||
|
||||
volumes:
|
||||
adapter-1c-data:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# CPU-only OpenAI-compatible embedding endpoint for 1C code search.
|
||||
# The model cache is persistent on docker-gpu; no credentials are required.
|
||||
|
||||
EMBEDDING_CONTAINER_NAME=llm-qwen3-embedding
|
||||
EMBEDDING_IMAGE=ghcr.io/ggml-org/llama.cpp@sha256:3e8914c1aab600a330ada97c10fb5fb02ff1c15ac39f2ece218db125cf54594e
|
||||
EMBEDDING_HOST_PORT=8082
|
||||
EMBEDDING_HF_REPO=Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0
|
||||
EMBEDDING_SERVED_MODEL_NAME=qwen3-embedding-0.6b
|
||||
EMBEDDING_CTX_SIZE=8192
|
||||
EMBEDDING_BATCH_SIZE=1024
|
||||
EMBEDDING_UBATCH_SIZE=1024
|
||||
EMBEDDING_THREADS=12
|
||||
EMBEDDING_PARALLEL=1
|
||||
EMBEDDING_CPU_LIMIT=8.0
|
||||
EMBEDDING_MEMORY_LIMIT=4G
|
||||
|
||||
# Windows path on docker-gpu.cin.su, mounted into the Linux container.
|
||||
HOST_LLAMA_CACHE_DIR=Z:/LLM/models/cache/llama.cpp
|
||||
@@ -0,0 +1,51 @@
|
||||
name: llm-embeddings
|
||||
|
||||
services:
|
||||
qwen3-embedding:
|
||||
image: ${EMBEDDING_IMAGE:-ghcr.io/ggml-org/llama.cpp@sha256:3e8914c1aab600a330ada97c10fb5fb02ff1c15ac39f2ece218db125cf54594e}
|
||||
container_name: ${EMBEDDING_CONTAINER_NAME:-llm-qwen3-embedding}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${EMBEDDING_HOST_PORT:-8082}:8080"
|
||||
volumes:
|
||||
- ${HOST_LLAMA_CACHE_DIR:-Z:/LLM/models/cache/llama.cpp}:/root/.cache/llama.cpp
|
||||
command:
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8080"
|
||||
- --hf-repo
|
||||
- ${EMBEDDING_HF_REPO:-Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0}
|
||||
- --alias
|
||||
- ${EMBEDDING_SERVED_MODEL_NAME:-qwen3-embedding-0.6b}
|
||||
- --embedding
|
||||
- --pooling
|
||||
- last
|
||||
- --ctx-size
|
||||
- ${EMBEDDING_CTX_SIZE:-8192}
|
||||
- --batch-size
|
||||
- ${EMBEDDING_BATCH_SIZE:-1024}
|
||||
- --ubatch-size
|
||||
- ${EMBEDDING_UBATCH_SIZE:-1024}
|
||||
- --threads
|
||||
- ${EMBEDDING_THREADS:-12}
|
||||
- --parallel
|
||||
- ${EMBEDDING_PARALLEL:-1}
|
||||
- --n-gpu-layers
|
||||
- "0"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- curl
|
||||
- --fail
|
||||
- --silent
|
||||
- http://localhost:8080/health
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 10m
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: ${EMBEDDING_CPU_LIMIT:-8.0}
|
||||
memory: ${EMBEDDING_MEMORY_LIMIT:-4G}
|
||||
@@ -3,7 +3,11 @@ ONEC_AGENT_CONTAINER_NAME=onec-agent
|
||||
ONEC_AGENT_HOST_PORT=8090
|
||||
ONEC_AGENT_DEFAULT_BASE_URL=http://docker-gpu.cin.su:8000
|
||||
ONEC_AGENT_DEFAULT_MODEL=qwen3-4b-instruct-2507
|
||||
ONEC_ADAPTER_URL=http://docker-gpu.cin.su:8011
|
||||
ONEC_MCP_URL=http://docker.cin.su:8021
|
||||
|
||||
# Keep false for ordinary coding agents; diagnostic storage routes stay in the
|
||||
# developer adapter/MCP surface.
|
||||
ONEC_AGENT_ALLOW_DIAGNOSTIC=false
|
||||
|
||||
# Для интеграции с несколькими ИИ провайдерскими конечными точками
|
||||
# Формат JSON:
|
||||
|
||||
@@ -18,8 +18,8 @@ services:
|
||||
ONEC_AGENT_DB_PATH: /app/data/onec-agent.db
|
||||
ONEC_AGENT_DEFAULT_BASE_URL: ${ONEC_AGENT_DEFAULT_BASE_URL:-http://docker-gpu.cin.su:8000}
|
||||
ONEC_AGENT_DEFAULT_MODEL: ${ONEC_AGENT_DEFAULT_MODEL:-qwen3-4b-instruct-2507}
|
||||
ONEC_ADAPTER_URL: ${ONEC_ADAPTER_URL:-http://docker-gpu.cin.su:8011}
|
||||
ONEC_ADAPTER_TOKEN: ${ONEC_ADAPTER_TOKEN:-}
|
||||
ONEC_MCP_URL: ${ONEC_MCP_URL:-http://docker.cin.su:8021}
|
||||
ONEC_AGENT_ALLOW_DIAGNOSTIC: ${ONEC_AGENT_ALLOW_DIAGNOSTIC:-false}
|
||||
ONEC_AGENT_PROVIDERS: ${ONEC_AGENT_PROVIDERS:-}
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -4,8 +4,12 @@ ADAPTER_1C_MCP_HOST_PORT=8021
|
||||
|
||||
# REST 1C adapter endpoint. The MCP proxy forwards onec_request(method,payload)
|
||||
# to this service. Change it when the real adapter container is deployed.
|
||||
ONEC_ADAPTER_URL=http://docker-gpu.cin.su:8011
|
||||
ONEC_ADAPTER_URL=http://docker.cin.su:8011
|
||||
ONEC_ADAPTER_TIMEOUT_SECONDS=120
|
||||
|
||||
# Keep false for ordinary coding agents. Enable only in a developer-only MCP
|
||||
# deployment that is allowed to expose storage diagnostics.
|
||||
ONEC_MCP_ALLOW_DIAGNOSTIC=false
|
||||
|
||||
# Optional bearer token for the REST adapter. Do not commit real secrets.
|
||||
ONEC_ADAPTER_TOKEN=
|
||||
|
||||
@@ -12,6 +12,29 @@ services:
|
||||
- "${ADAPTER_1C_MCP_HOST_PORT:-8021}:8021"
|
||||
environment:
|
||||
PORT: "8021"
|
||||
ONEC_ADAPTER_URL: ${ONEC_ADAPTER_URL:-http://docker-gpu.cin.su:8011}
|
||||
ONEC_ADAPTER_URL: ${ONEC_ADAPTER_URL:-http://docker.cin.su:8011}
|
||||
ONEC_ADAPTER_TOKEN: ${ONEC_ADAPTER_TOKEN:-}
|
||||
ONEC_ADAPTER_TIMEOUT_SECONDS: ${ONEC_ADAPTER_TIMEOUT_SECONDS:-240}
|
||||
ONEC_MCP_ALLOW_DIAGNOSTIC: ${ONEC_MCP_ALLOW_DIAGNOSTIC:-false}
|
||||
ONEC_MCP_DEBUG_DIAGNOSTICS: ${ONEC_MCP_DEBUG_DIAGNOSTICS:-false}
|
||||
volumes:
|
||||
- adapter-1c-mcp-data:/data
|
||||
|
||||
adapter-1c-mcp-audit:
|
||||
image: ${ADAPTER_1C_MCP_IMAGE:-adapter-1c-mcp:latest}
|
||||
container_name: ${ADAPTER_1C_MCP_AUDIT_CONTAINER_NAME:-adapter-1c-mcp-audit}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- adapter-1c-mcp
|
||||
volumes:
|
||||
- adapter-1c-mcp-data:/data
|
||||
environment:
|
||||
ONEC_MCP_AUDIT_INTERVAL_SECONDS: ${ONEC_MCP_AUDIT_INTERVAL_SECONDS:-900}
|
||||
command: >-
|
||||
sh -c 'mkdir -p /data/adapter-audit-reports;
|
||||
while true; do python /app/analyze_audit.py > /data/adapter-audit-reports/latest.json.tmp
|
||||
&& mv /data/adapter-audit-reports/latest.json.tmp /data/adapter-audit-reports/latest.json;
|
||||
sleep "$${ONEC_MCP_AUDIT_INTERVAL_SECONDS}"; done'
|
||||
|
||||
volumes:
|
||||
adapter-1c-mcp-data:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
ADAPTER_1C_IMAGE=adapter-1c-rest:latest
|
||||
ADAPTER_1C_CONTAINER_NAME=adapter-1c-rest
|
||||
|
||||
# Public REST port on docker.cin.su. Change it if 8011 is occupied, then use
|
||||
# the same value in ONEC_ADAPTER_URL for MCP and the 1C agent.
|
||||
ADAPTER_1C_HOST_PORT=8011
|
||||
|
||||
# Live SQL connections. Keep real credentials outside git.
|
||||
# Example:
|
||||
# ONEC_SQL_BASES_JSON={"upo_test":{"server":"sql-host","database":"upo_test","user":"configured_login","password_env":"ONEC_SQL_PASSWORD_UPO_TEST"}}
|
||||
# ONEC_SQL_PASSWORD_UPO_TEST=put-this-only-in-a-real-non-committed-env-file
|
||||
ONEC_SQL_BASES_JSON=
|
||||
# Optional path inside the container to a JSON file with the same shape as ONEC_SQL_BASES_JSON.
|
||||
ONEC_SQL_BASES_JSON_FILE=/data/onec-sql-bases.json
|
||||
|
||||
# Adapter-owned local SQLite state. Never point this at the 1C database.
|
||||
ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite
|
||||
ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true
|
||||
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0
|
||||
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0
|
||||
@@ -0,0 +1,63 @@
|
||||
name: adapter-1c
|
||||
|
||||
services:
|
||||
adapter-1c-rest:
|
||||
build:
|
||||
context: ../../../../plugins/1c
|
||||
dockerfile: connector/Dockerfile
|
||||
image: ${ADAPTER_1C_IMAGE:-adapter-1c-rest:latest}
|
||||
container_name: ${ADAPTER_1C_CONTAINER_NAME:-adapter-1c-rest}
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 5m
|
||||
ports:
|
||||
- "${ADAPTER_1C_HOST_PORT:-8011}:8011"
|
||||
volumes:
|
||||
- adapter-1c-data:/data
|
||||
environment:
|
||||
ONEC_ADAPTER_HOST: 0.0.0.0
|
||||
ONEC_ADAPTER_PORT: 8011
|
||||
ONEC_ADAPTER_SERVICE_TOKEN: ${ONEC_ADAPTER_SERVICE_TOKEN:-}
|
||||
ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN: ${ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN:-true}
|
||||
ONEC_SQL_BASES_JSON: ${ONEC_SQL_BASES_JSON:-}
|
||||
ONEC_SQL_BASES_JSON_FILE: ${ONEC_SQL_BASES_JSON_FILE:-/data/onec-sql-bases.json}
|
||||
ONEC_ADAPTER_CACHE_DB: ${ONEC_ADAPTER_CACHE_DB:-/data/adapter-cache.sqlite}
|
||||
ONEC_ADAPTER_STATE_DB: ${ONEC_ADAPTER_STATE_DB:-/data/adapter-cache.sqlite}
|
||||
ONEC_ADAPTER_JOB_STORE: ${ONEC_ADAPTER_JOB_STORE:-/data/adapter-jobs.json}
|
||||
ONEC_REPOSITORY_STATE_FILE: ${ONEC_REPOSITORY_STATE_FILE:-/data/onec-repository-locks.json}
|
||||
ONEC_ADAPTER_BACKUP_DIR: ${ONEC_ADAPTER_BACKUP_DIR:-/data/adapter-apply-backups}
|
||||
ONEC_CONFIGURATION_ACTIVATION_STATE_FILE: ${ONEC_CONFIGURATION_ACTIVATION_STATE_FILE:-/data/onec-configuration-activation-requests.json}
|
||||
ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS: ${ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS:-1800}
|
||||
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS: ${ONEC_ADAPTER_JOB_TIMEOUT_SECONDS:-240}
|
||||
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS: ${ONEC_ADAPTER_FULL_TIMEOUT_SECONDS:-600}
|
||||
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS: ${ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS:-180}
|
||||
ONEC_ADAPTER_JOB_PROCESS_ISOLATION: ${ONEC_ADAPTER_JOB_PROCESS_ISOLATION:-true}
|
||||
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB: ${ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB:-0}
|
||||
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS: ${ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS:-0}
|
||||
ONEC_ADAPTER_DEBUG_DIAGNOSTICS: ${ONEC_ADAPTER_DEBUG_DIAGNOSTICS:-false}
|
||||
ONEC_INFOBASE_USER_ADMIN_BASES_JSON: ${ONEC_INFOBASE_USER_ADMIN_BASES_JSON:-}
|
||||
ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE: ${ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE:-/data/onec-infobase-user-admin.json}
|
||||
ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST: ${ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST:-}
|
||||
ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED: ${ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED:-false}
|
||||
ONEC_REPOSITORY_RUNNER_TOKEN: ${ONEC_REPOSITORY_RUNNER_TOKEN:-}
|
||||
ONEC_ADAPTER_ENABLE_EXTERNAL_1C: ${ONEC_ADAPTER_ENABLE_EXTERNAL_1C:-false}
|
||||
ONEC_REPOSITORY_REQUEST_TTL_SECONDS: ${ONEC_REPOSITORY_REQUEST_TTL_SECONDS:-86400}
|
||||
ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS: ${ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS:-7200}
|
||||
|
||||
adapter-1c-audit:
|
||||
image: ${ADAPTER_1C_IMAGE:-adapter-1c-rest:latest}
|
||||
container_name: ${ADAPTER_1C_AUDIT_CONTAINER_NAME:-adapter-1c-audit}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- adapter-1c-rest
|
||||
volumes:
|
||||
- adapter-1c-data:/data
|
||||
environment:
|
||||
ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS: ${ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS:-900}
|
||||
command: >-
|
||||
sh -c 'mkdir -p /data/adapter-audit-reports;
|
||||
while true; do python /app/analyze_audit.py --log /data/adapter-audit.jsonl > /data/adapter-audit-reports/latest.json.tmp
|
||||
&& mv /data/adapter-audit-reports/latest.json.tmp /data/adapter-audit-reports/latest.json;
|
||||
sleep "$${ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS}"; done'
|
||||
|
||||
volumes:
|
||||
adapter-1c-data:
|
||||
@@ -0,0 +1,12 @@
|
||||
ADAPTER_OBSERVER_IMAGE=adapter-observer:latest
|
||||
ADAPTER_OBSERVER_CONTAINER_NAME=adapter-observer
|
||||
ADAPTER_OBSERVER_HOST_PORT=8031
|
||||
# Only needed for a copied deployment package; repository deployment uses default.
|
||||
# ADAPTER_OBSERVER_BUILD_CONTEXT=./observer
|
||||
# Existing adapter volume: mount is read-only in this service.
|
||||
ADAPTER_OBSERVER_AUDIT_VOLUME=adapter-1c_adapter-1c-data
|
||||
ADAPTER_OBSERVER_MCP_AUDIT_VOLUME=adapter-1c-mcp_adapter-1c-mcp-data
|
||||
ADAPTER_OBSERVER_ADAPTER_NETWORK=adapter-1c_default
|
||||
ONEC_OBSERVER_ADAPTER_URL=http://adapter-1c-rest:8011
|
||||
ONEC_OBSERVER_COVERAGE_BASE_ID=upo_test
|
||||
ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS=900
|
||||
@@ -0,0 +1,42 @@
|
||||
name: adapter-observer
|
||||
|
||||
services:
|
||||
adapter-observer:
|
||||
build:
|
||||
context: ${ADAPTER_OBSERVER_BUILD_CONTEXT:-../../../../plugins/1c/observer}
|
||||
dockerfile: Dockerfile
|
||||
image: ${ADAPTER_OBSERVER_IMAGE:-adapter-observer:latest}
|
||||
container_name: ${ADAPTER_OBSERVER_CONTAINER_NAME:-adapter-observer}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ADAPTER_OBSERVER_HOST_PORT:-8031}:8031"
|
||||
volumes:
|
||||
- adapter-1c-data:/audit:ro
|
||||
- adapter-1c-mcp-data:/mcp-audit:ro
|
||||
- adapter-observer-state:/state
|
||||
environment:
|
||||
ONEC_OBSERVER_HOST: 0.0.0.0
|
||||
ONEC_OBSERVER_PORT: 8031
|
||||
ONEC_OBSERVER_AUDIT_DIR: /audit
|
||||
ONEC_OBSERVER_MCP_AUDIT_DIR: /mcp-audit
|
||||
ONEC_OBSERVER_STATE_DIR: /state
|
||||
ONEC_OBSERVER_ADAPTER_URL: ${ONEC_OBSERVER_ADAPTER_URL:-http://adapter-1c-rest:8011}
|
||||
ONEC_OBSERVER_COVERAGE_BASE_ID: ${ONEC_OBSERVER_COVERAGE_BASE_ID:-upo_test}
|
||||
ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS: ${ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS:-900}
|
||||
networks:
|
||||
- default
|
||||
- adapter-1c
|
||||
|
||||
volumes:
|
||||
adapter-1c-data:
|
||||
external: true
|
||||
name: ${ADAPTER_OBSERVER_AUDIT_VOLUME:-adapter-1c_adapter-1c-data}
|
||||
adapter-1c-mcp-data:
|
||||
external: true
|
||||
name: ${ADAPTER_OBSERVER_MCP_AUDIT_VOLUME:-adapter-1c-mcp_adapter-1c-mcp-data}
|
||||
adapter-observer-state:
|
||||
|
||||
networks:
|
||||
adapter-1c:
|
||||
external: true
|
||||
name: ${ADAPTER_OBSERVER_ADAPTER_NETWORK:-adapter-1c_default}
|
||||
+340
-32
@@ -2,22 +2,18 @@
|
||||
|
||||
## Configuration repository control
|
||||
|
||||
The current adapter release is SQL-only. It does not start Designer, call a
|
||||
Windows runner, or inspect repository internals. External 1C execution is a
|
||||
future-version capability and is disabled by default with
|
||||
`ONEC_ADAPTER_ENABLE_EXTERNAL_1C=false`.
|
||||
The adapter is SQL-only. It does not start, automate, emulate, or require
|
||||
Designer/Configurator. Configurator actions are performed by a human and are
|
||||
outside the adapter's execution boundary.
|
||||
|
||||
Repository operations are available through `repository.status`,
|
||||
`repository.lock.plan`, `repository.lock`, `repository.lock.confirm`, `repository.lock.verify`,
|
||||
`repository.unlock`, `repository.commit.plan`, and `repository.commit`.
|
||||
Configuration is selected only by `payload.base_id`: the base runtime profile
|
||||
declares `repository.backend=direct|karman_bridge`, the Designer executable,
|
||||
infobase selector, endpoint, optional extension, users, and environment-variable
|
||||
names containing transient passwords. No repository, bridge, or endpoint name is
|
||||
hard-coded or inferred from naming conventions.
|
||||
Configuration is selected only by `payload.base_id`. No Designer executable,
|
||||
Configurator endpoint, bridge, or external 1C runner is used by this adapter.
|
||||
|
||||
`repository.lock_mode=automatic|manual` is also selected per base. Automatic
|
||||
mode uses the configured runner. Manual mode requires no Designer or runner:
|
||||
`repository.lock_mode=manual` is selected per base. Manual mode requires no
|
||||
Designer integration:
|
||||
`repository.lock.plan` returns the exact public development-object names to
|
||||
lock in Configurator, and `repository.lock.confirm` records the user's explicit
|
||||
confirmation for only that object set. Such a session is marked
|
||||
@@ -43,13 +39,11 @@ further SQL writes through that session.
|
||||
The adapter keeps a bounded audit trail of request creation, confirmation,
|
||||
cancellation, and closure and exposes it to the administrative requests view.
|
||||
|
||||
Both backends invoke standard Designer repository commands. A Karman/Filebox
|
||||
backend is an opaque native TCP transport and does not own credentials, object
|
||||
locks, or repository transactions. For configured bases, saved-state apply is
|
||||
blocked until the caller supplies an active adapter-owned `lock_session_id`.
|
||||
Commit additionally requires `allow_repository_commit=true` and a non-empty
|
||||
version comment. Unlock and commit operate only on the object set recorded for
|
||||
that adapter session.
|
||||
Repository coordination records only the human-confirmed scope. It does not
|
||||
invoke Designer repository commands and does not prove a native lock. For
|
||||
configured bases, saved-state apply is blocked until the caller supplies an
|
||||
active adapter-owned `lock_session_id`; that session is coordination evidence,
|
||||
not a native repository transaction.
|
||||
|
||||
Status: draft, read-only first.
|
||||
|
||||
@@ -64,6 +58,30 @@ Related work plan: `docs/1c-extension-layer-plan.md`.
|
||||
- When XML-derived rules are promoted into the adapter, the runtime write path
|
||||
must still resolve to concrete SQL storage targets such as `ConfigSave` or
|
||||
`ConfigCASSave`, with explicit gates and readback verification.
|
||||
- SQL readback is not proof that Configurator accepts, displays, saves, or
|
||||
activates a change. Only a human may provide that confirmation.
|
||||
- The adapter must reject, rather than fabricate, a `ConfigSave`/
|
||||
`ConfigCASSave` container or signature whose SQL protocol is not proven.
|
||||
|
||||
## Data Composition Schema Query Writes
|
||||
|
||||
`scd.patch` changes only one proven direct XML scalar: a dataset `query`, a
|
||||
calculated-field `expression`, or a resource `expression`. It resolves the
|
||||
report and SCD by public 1C names, preserves the surrounding SQL payload bytes
|
||||
and compression envelope, and never accepts a storage key from a caller.
|
||||
|
||||
For `execution_mode=apply`, `apply_and_verify`, or `apply_and_rollback`, a
|
||||
missing saved SCD payload is prepared internally through the established
|
||||
saved-state copy codec when `allow_sql_saved_state_prepare=true` is supplied.
|
||||
The caller still explicitly authorizes the eventual edit with
|
||||
`allow_saved_state_write=true` and `allow_sql_saved_state_apply=true`.
|
||||
|
||||
`apply_and_rollback` additionally requires
|
||||
`allow_sql_saved_state_rollback=true`. It restores the exact apply backup, not
|
||||
a newly encoded copy of the old query. If this request created the saved-state
|
||||
overlay, it then removes only receipt-recorded rows after hash precondition
|
||||
checks. A successful result proves SQL readback and cleanup only; it does not
|
||||
prove Configurator visibility, acceptance, or activation.
|
||||
|
||||
## User Identity And Access Terminology
|
||||
|
||||
@@ -123,8 +141,8 @@ Password mutation is available only for the platform `infobase_user` layer:
|
||||
`allow_administrator_password_change=true`;
|
||||
- normally both mutations are blocked when `ONEC_ADAPTER_SERVICE_TOKEN` is
|
||||
empty. A disposable isolated test stand may explicitly set
|
||||
`ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=true`; this also permits an
|
||||
unprotected runtime bridge endpoint and must never be enabled in production.
|
||||
`ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=true`; it affects only this
|
||||
isolated SQL operation and must never be enabled in production.
|
||||
|
||||
Both operations select the exact user through `infobase.user.get` and update
|
||||
only `dbo.v8users.Data`. The clear-text password for `set` exists only in memory
|
||||
@@ -411,6 +429,64 @@ Purpose:
|
||||
Normal coding agents should write BSL through `code.write`, not through SQL,
|
||||
storage rows, payload paths, or `metadata.module.write_apply`.
|
||||
|
||||
### Working-state and saved-state ownership rule
|
||||
|
||||
An agent works only with the effective working configuration. It reads the
|
||||
saved development version when one exists and otherwise reads active code; it
|
||||
does not determine whether `ConfigSave` or `ConfigCASSave` exists, and it must
|
||||
not call a separate saved-state preparation operation as part of a normal code
|
||||
change.
|
||||
|
||||
The adapter owns that transition. A `concrete_reference` returned by
|
||||
`code.search` or `code.read` is an opaque proof of the exact active module
|
||||
stream, extension, and layer—not permission to write active storage. After
|
||||
preflight has resolved that reference, a normal apply must do the following
|
||||
inside the adapter:
|
||||
|
||||
`code.write` accepts the same reference either directly or inside
|
||||
`write_selector.target`. It verifies the supplied public `ref`, extension,
|
||||
layer, canonical path, and routine against that exact stream, then routes it
|
||||
to the saved copy without repeating `metadata.object.modules` discovery.
|
||||
|
||||
For one apply request the adapter authorizes the public extension scope once
|
||||
before preparation. Its internal prepare and writer stages reuse that
|
||||
process-bound authorization; they must not repeat broad support/owner scans
|
||||
against `ConfigCASSave`. If authorization is absent, the request returns a
|
||||
bounded `blocked` result before any support scan or saved-state mutation.
|
||||
|
||||
For a client transport deadline shorter than the adapter write budget, call
|
||||
`adapter.job.start` with `method="code.write"` and the same public payload,
|
||||
then poll `adapter.job.get`. This preserves the final structured write result;
|
||||
the caller must not retry the SQL write while the job is running.
|
||||
|
||||
1. verify the exact extension/layer and active source stream;
|
||||
2. create a missing saved-state copy through the proven
|
||||
`Config`/`ConfigCAS` to `ConfigSave`/`ConfigCASSave` copy path;
|
||||
3. apply the guarded edit only to saved state;
|
||||
4. make a backup, validate BSL and the old-text/SHA preconditions, and reread
|
||||
the written result; and
|
||||
5. support the requested rollback, including removal or restoration of any
|
||||
adapter-created saved-state copy.
|
||||
|
||||
`metadata.write.preflight` reports such a first write as `status=ready` with
|
||||
`route.preparation.status=adapter_managed`,
|
||||
`route.preparation.caller_action_required=false`, and
|
||||
`saved_state.status=will_prepare`. `needs_prepare` is reserved for a route
|
||||
whose copy protocol is not sufficiently proven for the adapter to execute.
|
||||
The adapter must never write the active `Config` or `ConfigCAS` layer.
|
||||
|
||||
For a hash-keyed extension overlay, this internal copy includes the live
|
||||
extension root manifest (`root_cas_key`) and the confirmed object parts. An
|
||||
exact BSL stream reference remains the write target, but must not reduce the
|
||||
saved-state preparation to that leaf stream alone.
|
||||
|
||||
The caller never invokes or waits for this preparation as a separate workflow:
|
||||
`code.write` performs the bounded copy, write, verification, and readback as
|
||||
one adapter operation. Its preparation gate is restricted to the confirmed
|
||||
extension layer and public object; it does not perform a whole-extension
|
||||
support scan before copying proven source parts.
|
||||
|
||||
|
||||
`code.write` accepts 1C names and code text:
|
||||
|
||||
```json
|
||||
@@ -428,11 +504,21 @@ storage rows, payload paths, or `metadata.module.write_apply`.
|
||||
|
||||
Contract:
|
||||
|
||||
- default `mode` is `apply`, and apply means save to the working
|
||||
`ConfigSave`/`ConfigCASSave` layer, not production apply;
|
||||
- default `mode` is `plan` and does not write SQL. `apply`,
|
||||
`apply_and_verify`, and `apply_and_rollback` must be requested explicitly;
|
||||
they save only to the working `ConfigSave`/`ConfigCASSave` layer, not to
|
||||
production;
|
||||
- every `code.write` response includes `write_mode.target=saved_state`,
|
||||
`write_mode.activation_state=not_activated`, and
|
||||
`write_mode.production_apply=false`;
|
||||
- the normal agent view is the effective working configuration: a matching
|
||||
`ConfigSave`/`ConfigCASSave` overlay is read ahead of active code. Agents
|
||||
do not inspect, create, or select saved-state rows. For a first extension
|
||||
write, the adapter takes the exact active module handle returned by
|
||||
`code.search`/`code.read`, creates the proven `ConfigCASSave` copy inside
|
||||
its guarded apply workflow, and then edits that copy. Preflight reports this
|
||||
as `route.preparation.status=adapter_managed`; no separate prepare call is
|
||||
required from the agent.
|
||||
- saved-state `code.read` and `code.search` responses include
|
||||
`current_state.source=saved_state` and
|
||||
`current_state.activation_state=not_activated`;
|
||||
@@ -455,7 +541,9 @@ Contract:
|
||||
For embedded form modules the adapter writes only the scalar module token in
|
||||
the saved form payload with `path_preserve_format`. Whole-form payload
|
||||
canonicalization is forbidden because Designer may reject the form even if the
|
||||
payload decoder can parse it.
|
||||
payload decoder can parse it. A write plan for such a container returns a ready
|
||||
`code.write` apply hint rather than asking the caller to invent a
|
||||
`#stream:<index>`.
|
||||
|
||||
## Resolve Object
|
||||
|
||||
@@ -601,6 +689,22 @@ Important methods:
|
||||
Constants are exposed as a typed `value`; enumeration rows include their
|
||||
public value `name`, `synonym`, and `value_ref`. Business-process storage is
|
||||
resolved through the platform `_BPr<N>` route internally.
|
||||
- `additional_attributes.find` is the read-only entry point for additional
|
||||
requisites. It reads a public `ChartOfCharacteristicTypes` selector (by
|
||||
default `ДополнительныеРеквизитыИСведения`) and accepts `query` for a
|
||||
description search. Its `empty_source` result is deliberately distinct from
|
||||
`not_found`: it means that the resolved chart route has no records in the
|
||||
selected infobase, so no property reference or value type can be claimed.
|
||||
`additional_attributes.storage.resolve` requires a confirmed 32-hex
|
||||
`property_ref` plus `owner_ref`. It searches candidate information registers
|
||||
and returns a storage join only after live metadata proves dimensions
|
||||
`Объект` and `Свойство`, a `Значение` resource, and that `Свойство` is a
|
||||
reference to the selected PВХ. Otherwise it returns `unresolved` and never
|
||||
guesses physical `_Fld...` columns.
|
||||
For `ChartOfCharacteristicTypes`, `data.schema` and the find result also
|
||||
expose chart-level `allowed_value_types` with its live Config evidence path.
|
||||
This is explicitly not represented as the type of an individual property
|
||||
record until that record's `ТипЗначения` route is decoded.
|
||||
- `data.present` returns a compact presentation for one `record_ref`, and
|
||||
`data.movements` reads register rows for a `recorder_ref`.
|
||||
- BSP access-key methods use the same object/record separation without
|
||||
@@ -728,6 +832,10 @@ Important methods:
|
||||
`Модуль сервиса интеграции`. Handler names decoded by
|
||||
`metadata.object.properties` can therefore be followed directly into their
|
||||
live SQL module routines.
|
||||
- Exact extension objects are resolved by the same public `kind` + `name` or
|
||||
`ref` selectors as base objects. `metadata.object.modules` also returns form
|
||||
modules owned by the selected object, with qualified 1C names and without
|
||||
exposing CAS keys unless `include_storage=true`.
|
||||
- The same module APIs expose the four configuration-level modules through the
|
||||
public `Configuration.<Name>` selector: ordinary application, external
|
||||
connection, managed application, and session. Runtime discovery reads the
|
||||
@@ -859,6 +967,15 @@ Important methods:
|
||||
`module_ref` values remain valid for narrow follow-up cleanup. The method
|
||||
changes only adapter-local SQLite state, never the 1C SQL database; use
|
||||
`dry_run=true` to inspect the matching count without deleting cache rows.
|
||||
- `metadata.module_owner_cache.backfill` incrementally builds the reverse
|
||||
`module_ref -> 1C object` map from current metadata. One call processes a
|
||||
bounded object page and returns `next_cursor` (`kind_index`, `kind`,
|
||||
`offset`) until `complete=true`. The discovered public owner is propagated
|
||||
to existing lexical/vector code-index rows, including the matching
|
||||
active/saved-state table pair. By default objects without code-index rows
|
||||
are skipped so the operation remains fast; `deep=true` explicitly enables
|
||||
their slower metadata-module decoding. This changes adapter-local SQLite
|
||||
only.
|
||||
- For backward compatibility, a legacy bare `guid` without an owner selector
|
||||
may still identify a form. `object_guid` never gets that legacy treatment.
|
||||
- Logical schema results are cached briefly. `refresh_cache=true` forces a live
|
||||
@@ -905,7 +1022,11 @@ Important methods:
|
||||
code fragment. `state` is passed through to `modules.search`; the MCP
|
||||
`source_state=working` policy maps to this `state=working` mode.
|
||||
- `code.read`: wraps module/routine reads for agent-facing code analysis. It
|
||||
may set `source.kind=code_read`, but it must preserve the module `origin`
|
||||
accepts either `routine_name` or the inclusive `line_start`/`line_end` range.
|
||||
A focused request may use a previously decoded local code-index snapshot
|
||||
(`source.kind=code_index_cache`, `freshness.verified_against_sql=false`) to
|
||||
avoid decoding a large container again; pass `prefer_code_index=false` for
|
||||
the normal live SQL decode path. It must preserve the module `origin`
|
||||
evidence from `modules.read` so write planning can still distinguish base,
|
||||
saved state, extension, or unresolved CAS references.
|
||||
- `metadata.adapter.audit`: reports recognized metadata kinds, public kind
|
||||
@@ -1000,6 +1121,9 @@ Working source state:
|
||||
`comparison.both_present` plus `comparison.differs`. When `include_text=true`,
|
||||
top-level `text` is the effective programming text: saved-state text if it
|
||||
exists, otherwise active text. `text_source` names the layer used.
|
||||
For extension form modules, the saved counterpart is matched by extension,
|
||||
logical form owner, and form GUID rather than by assuming identical active
|
||||
and saved CAS file names.
|
||||
- `code.search state=both` also returns a mixed view for saved CommonForm code:
|
||||
saved-state matches are listed first, active matches are fetched with an
|
||||
independent `state=active` pass, and `counts.saved_matches` /
|
||||
@@ -2103,6 +2227,15 @@ Selector rules:
|
||||
row details. `include_storage=true` retains the low-level diagnostic response.
|
||||
`plan` remains the default and performs no write; `apply` and
|
||||
`apply_and_verify` still require `allow_sql_saved_state_prepare=true`.
|
||||
- `metadata.saved_state.ensure` is the preferred idempotent public facade for
|
||||
that operation. It resolves `ConfigSave` or `ConfigCASSave` from the public
|
||||
object/extension selector, copies only missing active parts, and never asks a
|
||||
user to create a missing save layer in Configurator. It remains a plan by
|
||||
default; its apply modes use the same explicit SQL gate.
|
||||
- `metadata.saved_state.ensure.rollback` removes only rows inserted by the
|
||||
opaque receipt returned from an ensure apply. It SHA-checks every row first,
|
||||
requires `allow_sql_saved_state_rollback=true`, and never touches active
|
||||
`Config` or `ConfigCAS`.
|
||||
- Every public RPC `next_resolution`/`next_call` entry uses `{method, params}`.
|
||||
`payload` is reserved for the outer RPC request envelope and internal
|
||||
apply-hint bodies; it must not be used as the arguments field of a public
|
||||
@@ -2134,6 +2267,74 @@ Selector rules:
|
||||
accepts the backward-compatible `table=ConfigSave|ConfigCASSave`, compares
|
||||
saved rows with their active source by file part, size, and SHA1, and returns
|
||||
per-file `changed`, `unchanged`, or `saved_only` statuses.
|
||||
- `configuration.activation.status` is the read-only activation boundary for
|
||||
`saved_state -> active`. It checks both layers by default, or accepts
|
||||
`layer=base_saved_state|extension_saved_state`, and delegates to the live SQL
|
||||
saved-state comparison above. It never uses the code cache or vector index as
|
||||
authority. `activation_required=true` means at least one `changed` or
|
||||
`saved_only` object exists. If the configured scan limit is reached without a
|
||||
detected difference, the result is `inconclusive` rather than a false
|
||||
`up_to_date`.
|
||||
- `configuration.activation.plan` uses that live status to build a read-only
|
||||
handoff. When changes exist, it returns a `{method, params}` review call, a
|
||||
manual Designer action (`Обновить конфигурацию базы данных`, F7), and a live
|
||||
verification call whose expected status is `up_to_date`. The current adapter
|
||||
does not start Designer or mutate the active configuration, even when
|
||||
`ONEC_ADAPTER_ENABLE_EXTERNAL_1C` is enabled. A future execution method must
|
||||
use a dedicated Designer bridge and separate explicit confirmation.
|
||||
- `configuration.activation.request` creates an expiring adapter-local request
|
||||
only when the live status is `activation_required`. The request fingerprint
|
||||
includes the exact pending storage files and their saved/active SHA1 values;
|
||||
storage coordinates and hashes are not returned as the public activation
|
||||
status. Requests and lifecycle events are stored outside the infobase in the
|
||||
local SQLite database selected by `ONEC_ADAPTER_STATE_DB`, in
|
||||
`configuration_activation_requests` and
|
||||
`configuration_activation_events`. The legacy JSON path selected by
|
||||
`ONEC_CONFIGURATION_ACTIVATION_STATE_FILE` is imported once and then remains
|
||||
read-only.
|
||||
- `configuration.activation.execute` currently accepts only `mode=debug` and
|
||||
requires `confirm_activation=true` plus the exact request id. It repeats the
|
||||
live SQL read and rejects the request if it expired, the base differs, the
|
||||
pending state disappeared, or any fingerprinted file changed. A successful
|
||||
result is `debug_accepted`; Designer is not started and the active
|
||||
configuration is not changed. Use
|
||||
`configuration.activation.request.status` to inspect the adapter-local
|
||||
request state.
|
||||
- `configuration.activation.request.cancel` requires `confirm_cancel=true` and
|
||||
records a lifecycle event for the exact request. Cancellation is idempotent
|
||||
and has no 1C side effect. `configuration.activation.audit` returns a bounded
|
||||
base-scoped list of request states and events without exposing the internal
|
||||
per-file fingerprint evidence.
|
||||
- `configuration.activation.capabilities` reports only boolean/configuration
|
||||
readiness: runner kind, presence of a Designer path and infobase selector,
|
||||
external-1C enable flag, and supported workflow gates. It never returns the
|
||||
executable path, runner URL, infobase selector, users, passwords, or tokens.
|
||||
The base layer reports documented `/UpdateDBCfg` as `debug_only`; extension
|
||||
activation reports `manual_only` until a verified platform command is
|
||||
implemented.
|
||||
- `configuration.activation.bridge.probe` performs an explicit debug-only
|
||||
readiness call. For a local runner it checks that the configured Designer
|
||||
executable exists and exactly one infobase selector is present. For an HTTP
|
||||
runner it calls `/configuration/activation/debug` using the existing runner
|
||||
token configuration. Both paths return only booleans and runner kind:
|
||||
Designer is not started, credential values are not read by the local probe,
|
||||
and paths, URLs, selectors, users, and secret names are not returned.
|
||||
- `configuration.activation.execute` accepts optional `bridge_debug=true`.
|
||||
After all request, expiry, confirmation, live-SQL, and fingerprint checks
|
||||
pass, the adapter sends only `base_id`, semantic layer, request id, and the
|
||||
64-hex fingerprint to the debug runner. The runner must return a matching
|
||||
request/fingerprint plus a 64-hex receipt. A missing, mismatched, or
|
||||
not-ready receipt blocks acceptance. A valid receipt records the
|
||||
`bridge_debug_accepted` lifecycle event; it still does not start Designer or
|
||||
change the active configuration.
|
||||
- `configuration.activation.verify` closes the manual activation loop for an
|
||||
exact request. It repeats the live SQL comparison with no cache. The result
|
||||
is `not_activated` when the original fingerprint is still pending,
|
||||
`changed_since_request` when pending files differ from the confirmed
|
||||
fingerprint, and `verified_up_to_date` only when saved and active layers
|
||||
align. SQL alignment does not prove that Designer performed activation—it
|
||||
can also result from discarding or replacing saved changes—so the response
|
||||
reports `activation_proven=false`.
|
||||
- `metadata.saved_state.changes.list` is the read-only pending-change overview
|
||||
across the base and extension saved-state layers. Use the semantic
|
||||
`layer=base_saved_state|extension_saved_state` filter when only one layer is
|
||||
@@ -2178,7 +2379,11 @@ Freshness statuses:
|
||||
- `cache_hit_verified`: cache candidate was rechecked against current SQL
|
||||
`payload_sha1` and `text_sha1`;
|
||||
- `cache_hit_stale`: cache candidate exists, but current SQL no longer matches;
|
||||
- `cache_refreshed_from_sql`: a stale cache row was rebuilt from current SQL
|
||||
and will be reranked before it can be returned;
|
||||
- `vector_candidate_unverified`: vector result is only a retrieval candidate.
|
||||
- `dirty_with_live_fallback`: local coverage is incomplete, so the response
|
||||
includes a direct live-SQL search for newly added code.
|
||||
|
||||
RPC methods:
|
||||
|
||||
@@ -2195,24 +2400,91 @@ RPC methods:
|
||||
}
|
||||
```
|
||||
|
||||
- `metadata.code_index.status`: reports cache/module/vector chunk counts;
|
||||
- `metadata.code_index.search`: fast lexical search over cached BSL, verifying
|
||||
candidates by default;
|
||||
- `metadata.code_index.status`: reports cache/module/vector chunk counts,
|
||||
pending outbox events, overlay tombstones, and the local snapshot token;
|
||||
- `metadata.code_index.search`: strict lexical search over cached BSL. Strict
|
||||
mode is the default: it overfetches, verifies against live SQL, refreshes and
|
||||
discards stale rows, and switches to live SQL while the index is dirty;
|
||||
- `metadata.code_index.verify`: verifies one `module_ref` against live SQL;
|
||||
- `metadata.code_index.refresh_changed`: verifies search candidates and refreshes
|
||||
stale modules from SQL;
|
||||
- `metadata.code_vector.search`: searches cached module/routine chunks with
|
||||
local hashing embeddings or supplied `query_embedding`, then revalidates by
|
||||
default.
|
||||
- `metadata.code_index.sync_pending`: processes exact dirty/outbox targets after
|
||||
saved-state writes;
|
||||
- `metadata.code_index.poll_changes`: detects Configurator/external changes.
|
||||
Saved tables are checked by default; use `include_active=true` for a less
|
||||
frequent active-configuration scan;
|
||||
- `metadata.code_vector.pending`: returns current BSL chunks that still need an
|
||||
embedding for a requested model, with `chunk_id` and `text_sha1`
|
||||
preconditions. Optional `chunk_kinds=["routine"]` and `max_text_chars`
|
||||
support fast incremental passes without falsely marking skipped long chunks
|
||||
as embedded;
|
||||
- `metadata.code_vector.embedding.upsert`: stores an external embedding only
|
||||
while those preconditions still match. Changed or removed chunks return
|
||||
`conflict`;
|
||||
- `metadata.code_vector.search`: hybrid lexical/vector search over cached
|
||||
module/routine chunks. It overfetches, verifies live SQL, reranks after stale
|
||||
refreshes, suppresses active rows shadowed by saved state, and returns
|
||||
name-first `object_ref`/`object_selector` fields.
|
||||
|
||||
If older global index rows have empty owners, run
|
||||
`metadata.module_owner_cache.backfill` page by page. Search also performs a
|
||||
cheap local owner lookup for top candidates. For base `Config`/`ConfigSave`
|
||||
modules whose part name starts with a GUID, the adapter resolves that GUID
|
||||
against current metadata, requires an exact identity match, and persists the
|
||||
verified public owner. It never launches a configuration-wide owner scan on
|
||||
the hot search path.
|
||||
|
||||
For active extension `ConfigCAS` modules, the adapter follows the current
|
||||
extension manifest from the exact CAS content hash to the owning descriptor,
|
||||
decodes its public kind/name, and returns the extension name/GUID in both
|
||||
`owner.extension` and `object_selector`. The manifest relation and descriptor
|
||||
are cached locally only after this current-SQL resolution. Hashes are never
|
||||
presented as object names.
|
||||
|
||||
`strict=true` and `verify=true` are the safe defaults. `strict=false` is a
|
||||
diagnostic/candidate mode and must not drive programming changes.
|
||||
|
||||
The adapter keeps revision, dirty/outbox, tombstone, and vector data in the
|
||||
local adapter SQLite database. It does not add project tables or settings to
|
||||
the 1C SQL database.
|
||||
|
||||
External embeddings are optional. The worker supports deterministic local
|
||||
hashing for tests and any OpenAI-compatible embedding endpoint:
|
||||
|
||||
```powershell
|
||||
python scripts\embed_1c_code_vectors.py `
|
||||
--base-id upo_test `
|
||||
--embedding-provider openai-compatible `
|
||||
--embedding-model <code-embedding-model> `
|
||||
--embedding-base-url http://<embedding-host>:<port> `
|
||||
--json
|
||||
|
||||
python scripts\search_1c_code_vectors.py "где рассчитывается налог" `
|
||||
--base-id upo_test `
|
||||
--embedding-provider openai-compatible `
|
||||
--embedding-model <code-embedding-model> `
|
||||
--embedding-base-url http://<embedding-host>:<port> `
|
||||
--embed-pending `
|
||||
--json
|
||||
```
|
||||
|
||||
API keys are read only from the configured environment variable and are not
|
||||
written to SQLite, responses, or project files. The embedding model label and
|
||||
vector dimensions are stored so vectors from different models cannot be mixed.
|
||||
For Matryoshka-capable OpenAI-compatible models the code worker includes the
|
||||
requested dimension in the cache label (for example `@d384`), slices a longer
|
||||
response deterministically, and normalizes the stored vector again.
|
||||
|
||||
Operational modes:
|
||||
|
||||
- `fast`: cache plus SQL hash verification;
|
||||
- `fast`: strict cache search plus SQL hash verification;
|
||||
- `live`: direct SQL search/read, slower but authoritative;
|
||||
- `background_refresh`: intended for long cache warming jobs.
|
||||
|
||||
Never apply code changes from cache or vector output alone. Use the returned
|
||||
`read_selector` after freshness is `cache_hit_verified` or read live SQL again.
|
||||
`read_selector` after freshness is `cache_hit_verified`, then run the normal
|
||||
live write preflight. A non-null snapshot token identifies a clean local index;
|
||||
it is not a native 1C repository lock.
|
||||
|
||||
## Saved-State Form Search And Write Target Resolve
|
||||
|
||||
@@ -2353,12 +2625,23 @@ Purpose:
|
||||
|
||||
- build a routine chain for a concrete object and method name across base and
|
||||
extension modules;
|
||||
- include modules of forms owned by the selected object, including extension
|
||||
objects resolved from a public `ref`/`kind`/`name` without requiring an
|
||||
extension GUID or CAS key;
|
||||
- apply the same source policy as code reads: `state=working` prefers the saved
|
||||
form-module counterpart with active fallback, `state=save` is saved-only,
|
||||
`state=active` is active-only, and `state=both` can report both versions;
|
||||
- mark every chain link with `activation_state=active|saved_state`, so a saved
|
||||
routine is never mistaken for already applied runtime code;
|
||||
- return public read selectors for every found routine without exposing storage
|
||||
ids by default;
|
||||
- expose `chain[].extension_action` for each routine link. For base
|
||||
configuration links this is `operation_class=base_definition`;
|
||||
- for extension links, normalize known action evidence into
|
||||
`insert_before`, `insert_after`, `replace`, or `replace_with_control`;
|
||||
- classify an unannotated routine of an object defined by the extension itself
|
||||
as `extension_definition`; it is not an unresolved interception of a base
|
||||
routine and therefore does not trigger the unknown-action write guard;
|
||||
- when a routine is found in an extension but the action metadata is not yet
|
||||
resolved, return `extension_action.status=unknown` and
|
||||
`operation_class=unknown_extension_action`. The agent must not treat this as
|
||||
@@ -2967,6 +3250,31 @@ Purpose:
|
||||
- return backup ids, source metadata, sha1 and byte counts without returning
|
||||
rollback payload hex.
|
||||
|
||||
## Saved-State Backup Retention
|
||||
|
||||
RPC method:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "storage.saved_state.backups.prune",
|
||||
"payload": {
|
||||
"base_id": "<base-id>",
|
||||
"table": "ConfigCASSave",
|
||||
"older_than_days": 30,
|
||||
"keep_latest": 20,
|
||||
"limit": 500,
|
||||
"dry_run": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The method only inspects adapter-local backup JSON files. It is base-scoped,
|
||||
defaults to `dry_run=true`, preserves `keep_latest` matching backups, and never
|
||||
writes the 1C database. Actual deletion additionally requires
|
||||
`dry_run=false` and `confirm_delete=true`. Backups referenced by
|
||||
`metadata.write.history` are never selected for deletion. If history lookup is
|
||||
unavailable for a base, its matching backup files remain protected.
|
||||
|
||||
## Kind Smoke
|
||||
|
||||
Command:
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Date: 2026-06-20
|
||||
|
||||
The maintained evidence log for saved-state encoding, extension overlays,
|
||||
Configurator reload behaviour, and failed protocol paths is split by topic in
|
||||
[`docs/1c-sql-protocol/`](1c-sql-protocol/README.md). This specification keeps
|
||||
only universal format rules.
|
||||
|
||||
This document describes universal 1C SQL storage rules observed and verified so
|
||||
far. It must not contain knowledge about a particular infobase object such as
|
||||
`АвансовыйОтчет`, except as test evidence in a separate report.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# SQL protocol knowledge base for 1C configuration storage
|
||||
|
||||
This directory is the durable, evidence-first record for the 1C SQL adapter.
|
||||
It complements [the general format specification](../1c-sql-format-spec.md).
|
||||
Do not put all observations into one chronological note: add a fact to the
|
||||
document for its layer, object type, codec, operation, or experiment.
|
||||
|
||||
## Non-negotiable runtime rule
|
||||
|
||||
The adapter works **only through SQL**. It may decode and encode only what was
|
||||
observed in the target database and proven by round-trip checks. It must never
|
||||
invent a metadata object, physical path, BSL fragment, form element, join,
|
||||
codec, or cryptographic value. Unknown or ambiguous work returns an explicit
|
||||
unsupported/protocol-incomplete result.
|
||||
|
||||
The Configurator remains a human-operated consumer of the saved-state overlay.
|
||||
The adapter does not start it, automate it, or claim to change its in-memory
|
||||
state.
|
||||
|
||||
## Map
|
||||
|
||||
- [Rules and evidence discipline](rules/evidence-and-safety.md)
|
||||
- [Agent contract: do not invent](rules/agent-contract.md)
|
||||
- [Configuration SQL layers](layers/configuration-storage.md)
|
||||
- [Extension saved-state overlay](extensions/saved-state-overlay.md)
|
||||
- [Common form and BSL module handling](objects/common-form-module.md)
|
||||
- [Object component graph and child selectors](objects/component-graph.md)
|
||||
- [Report object-module carrier](objects/report-object-module.md)
|
||||
- [Payload envelope codec](codecs/payload-envelope.md)
|
||||
- [`__configinfo` map and service atom](codecs/configinfo.md)
|
||||
- [Configurator refresh behaviour](operations/configurator-cache.md)
|
||||
- [Reproducible `upo_test/test2` experiment](experiments/upo-test-test2.md)
|
||||
- [Known dead ends and prohibited shortcuts](research/known-dead-ends.md)
|
||||
- [Template for the next experiment](templates/experiment-record.md)
|
||||
- [Current adapter component map](implementation/adapter-components.md)
|
||||
|
||||
## Maintenance rule
|
||||
|
||||
After every material protocol investigation, record all three outcomes:
|
||||
|
||||
1. proven direction and the exact evidence;
|
||||
2. failed direction and why it failed;
|
||||
3. remaining unknowns and the next safe experiment.
|
||||
|
||||
Keep raw payloads and credentials out of git. Reference private learning
|
||||
artifacts by opaque ID or SHA-1 only.
|
||||
@@ -0,0 +1,35 @@
|
||||
# `__configinfo`: file map and service atom
|
||||
|
||||
## Proven map
|
||||
|
||||
The decoded `E__configinfo` text contains logical file-name pairs:
|
||||
|
||||
```text
|
||||
"<object-guid>.0",<base64 of 20-byte SHA-1>
|
||||
```
|
||||
|
||||
The Base64 value is the SHA-1 of the complete raw `BinaryData` stream for that
|
||||
logical file. Updating a module without replacing this map value produces the
|
||||
Configurator error “Ошибка хеш-версии файла конфигурации”.
|
||||
|
||||
The map rewrite is deterministic and losslessly round-trip proven for the
|
||||
studied extension form module.
|
||||
|
||||
## Service atom: current status
|
||||
|
||||
The root block also contains three observed binary atoms: two 48-byte values
|
||||
and a 32-byte value. The 32-byte value changed across manual saves, including
|
||||
when the module bytes returned exactly to a previous SHA-1. A random mutation
|
||||
of its variable part triggered a platform licensing/crypto error.
|
||||
|
||||
Therefore:
|
||||
|
||||
- preserve all service atoms byte-for-byte during the proven incremental
|
||||
protocol;
|
||||
- do not synthesise, randomise, zero, or “recalculate” them;
|
||||
- do not claim their reverse codec is known;
|
||||
- record new observations in an experiment file before changing this rule.
|
||||
|
||||
The `test2` map-update experiment activated successfully while preserving the
|
||||
existing service atom. This is activation evidence for preservation, not for
|
||||
generation.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Payload envelope codec
|
||||
|
||||
## Observed codec pipeline
|
||||
|
||||
For the studied configuration payloads:
|
||||
|
||||
```text
|
||||
SQL BinaryData
|
||||
→ raw-deflate (or detected alternative envelope)
|
||||
→ UTF-8 text, sometimes BOM
|
||||
→ brace-based 1C serialized value tree / container text
|
||||
```
|
||||
|
||||
The decoder must retain envelope type, original encoding, BOM, line endings,
|
||||
and all unmodified text. An encoder is valid only if an unchanged
|
||||
decode→encode cycle returns byte-identical payload bytes.
|
||||
|
||||
## Lossless transform discipline
|
||||
|
||||
1. Decode source bytes.
|
||||
2. Identify the exact BSL or value-tree slice with a proven extractor.
|
||||
3. Replace only that slice.
|
||||
4. Encode with the original codec metadata.
|
||||
5. Calculate SHA-1 from the final raw stored bytes, never from decoded text.
|
||||
|
||||
Do not canonicalize braces, whitespace, strings, BOM, compression level, or
|
||||
base64 blocks without an independently proven canonical writer.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `upo_test` / `test2` saved-state experiment
|
||||
|
||||
## Scope
|
||||
|
||||
- Test base: `upo_test`
|
||||
- Extension: `test2`
|
||||
- Extension GUID: `fb26cf42-7609-11f1-828f-005056b0d483`
|
||||
- Common form: `t_Форма`
|
||||
- Form GUID: `77494708-43ea-4956-ac3c-199cfb035ad2`
|
||||
- Independently manually edited form: `tt_Форма3`
|
||||
- Form GUID: `99590008-addf-49fa-9ada-24962756d0cf`
|
||||
|
||||
These are experiment identifiers, not a universal hardcoded route.
|
||||
|
||||
## Follow-up cross-extension confirmation (2026-08-13)
|
||||
|
||||
The same logical-file protocol was exercised on a different extension and
|
||||
object class:
|
||||
|
||||
- Extension: `фс_Отчеты`
|
||||
- Extension GUID: `9b11f844-3d08-11f1-8287-005056b0d483`
|
||||
- Object: `Report._ПоступлениеТовара`
|
||||
- Object GUID: `c428f629-785a-4141-b038-f2192bb4580d`
|
||||
- Guarded change: `//Пример - 3` → `//Тест - 3`
|
||||
|
||||
The active root was a hash-keyed `ConfigCAS` record, but decoding it exposed
|
||||
the logical `O` / `O.0` map and the SHA-1 of each active payload. The adapter
|
||||
created exactly `E__configinfo`, `E__O`, `E__O.0`, executed a public
|
||||
`code.write` replacement and reread it. A controlled `apply_and_rollback`
|
||||
passed before `apply_and_verify`. A human then confirmed `//Тест - 3` in the
|
||||
Configurator extension editor.
|
||||
|
||||
This proves the canonical object-module overlay route for this observed
|
||||
extension-root family. The implementation must retain the suffix of the exact
|
||||
resolved BSL stream: a separately diagnosed production route for
|
||||
`Report.УОП_ПечатьЦенниковАссортимента` resolves its manager module as `.2`,
|
||||
not `.0`. This does not authorize a guessed suffix: the selected `O.S` entry
|
||||
and its SHA-1 must be present in the decoded root map. Forms or an unknown
|
||||
root that cannot be decoded into that exact logical map remain unproven.
|
||||
|
||||
## Outcomes
|
||||
|
||||
1. Writing a `.0` module alone made a comment visible after a reload but
|
||||
failed activation with a hash-version error.
|
||||
2. Pairing the module write with the matching `__configinfo` map update made
|
||||
the comment activate into `ConfigCAS`; Configurator cleared
|
||||
`ConfigCASSave` after successful application.
|
||||
3. Building a complete three-file initial overlay while Configurator was
|
||||
closed, then opening and applying it, succeeded for `change-10`.
|
||||
4. A manual save in `tt_Форма3` produced a three-row pending overlay for that
|
||||
form plus `__configinfo`. Incrementally adding `t_Форма` descriptor/module
|
||||
while preserving the existing rows and map entries kept both edits.
|
||||
5. A pending `__configinfo` map can point at a newer active module than an
|
||||
earlier inspection of `ConfigCAS`; selecting the source through the pending
|
||||
map avoids duplicate BSL comments.
|
||||
|
||||
## Failed paths retained as evidence
|
||||
|
||||
- Raw module-only write: invalid collection hash.
|
||||
- Random service-atom bytes: licensing/crypto error.
|
||||
- Treating the active module as authoritative while a pending map exists:
|
||||
duplicate comment insertion.
|
||||
- Assuming UI text means SQL save: the editor buffer can differ from
|
||||
`ConfigCASSave`; verify SQL immediately after saving.
|
||||
|
||||
## Verification after every apply
|
||||
|
||||
1. `ConfigCASSave` for the extension becomes empty.
|
||||
2. Extension root key in `_ExtensionsInfo`/`ConfigCAS` changes to the new
|
||||
`__configinfo` SHA-1.
|
||||
3. Active `ConfigCAS` contains the requested BSL text.
|
||||
4. The active map points to the SHA-1 of the active module bytes.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Extension saved-state overlay (`ConfigCASSave`)
|
||||
|
||||
> **Deployment and integration-test target:** `adapter-1c-mcp` runs on
|
||||
> `docker.cin.su`. Use that host for deployment and live adapter checks;
|
||||
> `test-docker` is staging only and requires an explicit request.
|
||||
|
||||
## Canonical object-module overlay: activation evidence
|
||||
|
||||
For extension GUID `E` and object GUID `O`, the overlay uses:
|
||||
|
||||
```text
|
||||
E__configinfo
|
||||
E__O
|
||||
E__O.S
|
||||
```
|
||||
|
||||
This route is now human-confirmed in two independently selected extension
|
||||
object-module cases on `upo_test`:
|
||||
|
||||
| Extension | Object | Public test | Human confirmation |
|
||||
|---|---|---|---|
|
||||
| `test2` | Common form/module fixture | prior `change-10` experiment | Configurator applied overlay into `ConfigCAS` |
|
||||
| `фс_Отчеты` | `Report._ПоступлениеТовара` object module | `//Пример - 3` → `//Тест - 3` | Configurator displayed `//Тест - 3` in the extension editor |
|
||||
|
||||
The descriptor and module are separate objects. A pending extension may contain
|
||||
only rows changed manually by the user; the root `__configinfo` map can still
|
||||
refer to unchanged active parts. An adapter must preserve those rows and all
|
||||
their map entries.
|
||||
|
||||
For the separately observed hash-keyed overlay, the equivalent boundary is the
|
||||
live extension `root_cas_key`: an automatic first-write copy must include that
|
||||
root manifest together with the selected object parts. A leaf module key alone
|
||||
is not a complete working-copy selection boundary.
|
||||
|
||||
## Hash-keyed first-write status
|
||||
|
||||
In `upo_test` / `фс_Отчеты`, copying the complete evidenced hash-key group
|
||||
from `ConfigCAS` to `ConfigCASSave` under unchanged hash names produced a
|
||||
byte-for-byte SQL readback, but Configurator continued to read the active
|
||||
`ConfigCAS` module. Therefore a same-name hash copy is **not** a working-copy
|
||||
protocol.
|
||||
|
||||
The active extension root has since been decoded as the equivalent logical
|
||||
file map: it contains `"<object-guid>[.suffix]",Base64(SHA-1(payload))`
|
||||
pairs, including the target descriptor and the selected BSL module. For the evidenced
|
||||
object-module family the initial overlay maps exactly three active files to:
|
||||
|
||||
```text
|
||||
root manifest -> E__configinfo
|
||||
object descriptor -> E__O
|
||||
object `.S` module -> E__O.S, where `.S` is the suffix of the exact resolved
|
||||
BSL stream (for example `.0` or `.2`)
|
||||
```
|
||||
|
||||
The adapter must reject any incomplete route and must never fall back to
|
||||
unchanged hash names. The map and all service atoms are copied verbatim on the
|
||||
first overlay; subsequent writes update only the proven file-SHA reference.
|
||||
|
||||
### Exact prepare/write algorithm
|
||||
|
||||
1. Resolve the extension, public object `O`, active descriptor and active `.S`
|
||||
BSL stream from the extension manifest.
|
||||
2. Decode the selected active root. Require exactly one map entry for `O` and
|
||||
exactly one for the selected `O.S`; require their SHA-1 values to equal the selected
|
||||
active descriptor/module payloads.
|
||||
3. If the overlay is absent, atomically copy only those three sources as
|
||||
`E__configinfo`, `E__O`, and `E__O.S`. Do not copy the whole extension.
|
||||
4. If it exists, preserve pending rows and maps; never overwrite another
|
||||
change. Prepare may add only missing object parts.
|
||||
5. Replace BSL only after the exact old fragment, source SHA-1, extension,
|
||||
public object and module stream agree. For an object module, update the one
|
||||
`O.S` SHA-1 value in `E__configinfo` in the same transaction and preserve
|
||||
service atoms byte-for-byte.
|
||||
6. Re-read the BSL and both changed rows. `apply_and_rollback` must restore
|
||||
the module and remove adapter-created first-overlay rows.
|
||||
|
||||
`code.write` callers provide only public selectors and the replacement. The
|
||||
adapter owns storage mapping, preparation, paired update and rollback.
|
||||
|
||||
## Rejected approaches (retain as regression hazards)
|
||||
|
||||
- Copying `ConfigCAS` hash rows to `ConfigCASSave` under the same names. It
|
||||
produced correct SQL readback but Configurator ignored it for `фс_Отчеты`.
|
||||
- Writing only the BSL module payload. The map continues to point to the old
|
||||
SHA-1 and Configurator reports a collection/hash-version error.
|
||||
- Generating `__configinfo`, its service atoms, or logical file mappings from
|
||||
names alone. The adapter must first decode the actual selected root map.
|
||||
- Replacing the entire compressed module container. Only the declared BSL
|
||||
prefix codec is allowed; opaque stream tail bytes must remain unchanged.
|
||||
- Resolving an already selected object-module path through generic metadata
|
||||
path traversal. Its module suffix is a BSL container, so this can fail before the
|
||||
saved-state writer sees the exact module route.
|
||||
|
||||
## Proven incremental write protocol
|
||||
|
||||
For a form module change:
|
||||
|
||||
1. Read the current `E__configinfo` from `ConfigCASSave` if it exists;
|
||||
otherwise derive a complete initial overlay from active `ConfigCAS`.
|
||||
2. Resolve the form descriptor and module hashes from that map.
|
||||
3. Patch the module with a unique BSL anchor and lossless payload codec.
|
||||
4. Replace exactly the Base64(SHA-1(raw module bytes)) value paired with
|
||||
logical name `O.0` in `E__configinfo`.
|
||||
5. Preserve every unrelated map entry and service atom byte-for-byte.
|
||||
6. In one transaction insert missing `E__O`/`E__O.0` rows and update
|
||||
`E__configinfo`, with compare-and-set SHA-1 preconditions.
|
||||
|
||||
The adapter core exposes a pure `build_extension_saved_state_pair_plan` helper
|
||||
for steps 3–5. It refuses a plan when the ConfigInfo map does not reference the
|
||||
current saved stream, so a later transaction cannot silently overwrite a
|
||||
divergent human overlay.
|
||||
|
||||
For an already existing single-part pending stream, the paired writer locks the
|
||||
module and `__configinfo`, verifies both preconditions, writes both payloads
|
||||
and their `DataSize` values in one transaction, keeps independent rollback
|
||||
evidence, and verifies both rows afterwards. `__configinfo` itself is never a
|
||||
primary editable stream. Missing overlay rows still use the separate prepare
|
||||
route before this writer may update them.
|
||||
|
||||
This protocol was activation-proven in the `test2` experiment. It is not yet
|
||||
a universal proof for every 1C platform version or every extension object
|
||||
class; new classes require their own evidence record.
|
||||
|
||||
## Important overlay behaviour
|
||||
|
||||
An external SQL overlay write does not set the Configurator’s in-memory dirty
|
||||
flag. Its visibility therefore depends on the current session. See
|
||||
[Configurator refresh behaviour](../operations/configurator-cache.md).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Current adapter component map
|
||||
|
||||
This map says where to extend the project without losing protocol boundaries.
|
||||
It is not a substitute for reading the linked source before editing it.
|
||||
|
||||
| Component | Location | Responsibility | Evidence boundary |
|
||||
|---|---|---|---|
|
||||
| RPC/service orchestration | `plugins/1c/connector/adapter_1c_server.py` | Base resolution, live SQL calls, saved-state plans/apply, verification, backups | Must keep active layers read-only |
|
||||
| Payload codec | `plugins/1c/parser/payload.py` | Envelope detection, decompression, lossless encode metadata | Require byte-identical unchanged round trip |
|
||||
| Brace parser | `plugins/1c/parser/*` | Decode 1C serialized brace/value trees | A parse tree is not semantic proof by itself |
|
||||
| Extension routes | `plugins/1c/parser/extensions.py` and adapter extension resolvers | `_ExtensionsInfo` and `ConfigCAS` manifest routes | Never infer part role from suffix alone |
|
||||
| DBNames mapping | `plugins/1c/parser/dbnames.py` | Metadata-to-physical SQL role evidence | Names/roles must come from live DBNames evidence |
|
||||
| Storage access | adapter storage helpers | Single/multi-part row reads, hashes, paired extension map updates, compare-and-set and backups | Writes only to allowed saved-state tables |
|
||||
| Policy | `plugins/1c/connector/policies/` | Runtime boundary and base-access rules | Policy must match tested capability, not aspiration |
|
||||
| Regression tests | `tests/1c/` and `scripts/smoke_1c_*` | Preserve routes and observed protocol rules | Add fixture/test before promoting a decoder rule |
|
||||
|
||||
## Required update order for a new object type
|
||||
|
||||
1. Add an experiment record with before/after SQL evidence.
|
||||
2. Add/extend a decoder fixture and test.
|
||||
3. Add the narrow codec or route resolver.
|
||||
4. Add a writer only after lossless round-trip, paired-index handling, rollback,
|
||||
and human activation verification.
|
||||
5. Update the relevant topic document and this map if ownership changed.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Configuration storage layers
|
||||
|
||||
The following SQL tables have the same observed storage-row shape:
|
||||
|
||||
`FileName`, `Creation`, `Modified`, `Attributes`, `DataSize`, `BinaryData`,
|
||||
`PartNo`.
|
||||
|
||||
| Layer | Role | Direct adapter writes |
|
||||
|---|---|---|
|
||||
| `Config` | Active base configuration | Forbidden |
|
||||
| `ConfigSave` | Pending base configuration changes | Controlled `upo_test` only |
|
||||
| `ConfigCAS` | Active extension content-addressed store | Forbidden |
|
||||
| `ConfigCASSave` | Pending extension configuration overlay | Controlled `upo_test` only |
|
||||
|
||||
`PartNo` must be read and preserved. The currently proven writer handles a
|
||||
single-part stream only; a multi-part stream is unsupported until a
|
||||
table-aware round-trip codec exists.
|
||||
|
||||
## Addressing rules
|
||||
|
||||
Base `Config` objects are commonly addressed by metadata GUID. Extension active
|
||||
objects are reached through `_ExtensionsInfo` → extension root in `ConfigCAS`
|
||||
→ root manifest → object part SHA-1 key. Do not infer a semantic role from a
|
||||
suffix such as `.0`; inspect the payload and route evidence.
|
||||
|
||||
For normal content-addressed rows, `ConfigCAS.FileName` was observed to equal
|
||||
SHA-1 of the stored bytes. Always compute and compare the hash rather than
|
||||
trusting the name: an activation experiment left a legacy alias whose name was
|
||||
an old key while its bytes had the new SHA-1.
|
||||
|
||||
## Write sequence
|
||||
|
||||
1. Resolve public object/form/module against live SQL.
|
||||
2. Read active bytes and any existing saved-state rows.
|
||||
3. Prepare only missing saved rows; never overwrite a user’s existing rows.
|
||||
4. Encode the changed stream losslessly.
|
||||
5. Update every proved companion index in the same SQL transaction.
|
||||
6. Read back and validate hashes and semantic exact-match count.
|
||||
7. Tell the human which Configurator scope to reload.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Common form modules
|
||||
|
||||
## Proven form facts
|
||||
|
||||
The `test2` common forms `t_Форма` and `tt_Форма3` are distinct objects with
|
||||
distinct GUIDs and descriptors. A label must be decoded from the descriptor;
|
||||
do not shorten or normalize it by guesswork (`tt_Форма3` is not `t_Форма3`).
|
||||
|
||||
A form `.0` payload can contain more than a simple standalone BSL string. The
|
||||
adapter must use the proven container extractor and preserve all non-BSL
|
||||
segments, including form settings and command metadata.
|
||||
|
||||
## Module patch rules
|
||||
|
||||
- Obtain the BSL region from the decoded container, not from a global text
|
||||
search over compressed bytes.
|
||||
- Count the requested anchor in the relevant BSL region.
|
||||
- Replace a unique exact fragment once; report ambiguity otherwise.
|
||||
- Re-encode using the source payload’s detected codec and line-ending style.
|
||||
- Read back, re-extract BSL, and verify the target occurrence count.
|
||||
|
||||
## Pending-state resolution
|
||||
|
||||
When `ConfigCASSave` contains a `__configinfo` map, that map is authoritative
|
||||
for the pending overlay even if the corresponding module row is absent. The
|
||||
active `ConfigCAS` source must be selected through the saved map before a new
|
||||
delta row is created. Reading only active content can incorrectly conclude
|
||||
that a pending comment is absent and cause a duplicate insertion.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Граф составляющих объекта конфигурации
|
||||
|
||||
Объект конфигурации — это корень, а не минимальная единица работы. Его
|
||||
составляющие: модуль объекта, формы и их модули, макеты, схема компоновки
|
||||
данных (СКД), а после декодирования СКД — наборы данных, запросы, поля,
|
||||
ресурсы и варианты. Для записи нужен адрес именно составляющей и доказанный
|
||||
физический носитель этой составляющей.
|
||||
|
||||
## Публичная инвентаризация
|
||||
|
||||
`metadata.object.components` — read-only фасад над уже доказанными SQL
|
||||
декодерами. Он принимает обычный селектор владельца (`ref` либо `kind` и
|
||||
`name`, при необходимости `extension`) и возвращает граф:
|
||||
|
||||
- корень `metadata_object`;
|
||||
- только реально найденные `module`, `form`, `template` и `scd`;
|
||||
- публичные `path` и `read_selector` каждого потомка;
|
||||
- `unresolved` для областей, которые не были подтверждены.
|
||||
|
||||
Метод не создаёт потомок потому, что он обычно есть у такого типа объекта.
|
||||
Например, пустой ответ `metadata.object.modules` у общей формы означает
|
||||
«модуль этим маршрутом не найден», а не повод назвать форму модулем.
|
||||
|
||||
Пример запроса:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "metadata.object.components",
|
||||
"payload": {
|
||||
"base_id": "upo_test",
|
||||
"extension": "test2",
|
||||
"ref": "Report.tt_Отчет",
|
||||
"include_storage": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`read_selector` можно передать следующему read-only методу. Это не является
|
||||
разрешением на запись: запись выбирает компонент по его `path`, повторно
|
||||
подтверждает конкретный carrier/stream и разрешается только для семейства
|
||||
контейнеров с доказанным byte-preserving кодеком.
|
||||
|
||||
## Иерархия и кодеки
|
||||
|
||||
Логический BSL один и тот же независимо от владельца, но его физический
|
||||
носитель может различаться. Поэтому не нужны отдельные эвристики «для формы»
|
||||
и «для отчёта»: нужен реестр семейств carrier-кодеков. Один кодек применяется
|
||||
к нескольким типам объектов лишь после доказательства одинаковых границ,
|
||||
непрозрачных атомов и round-trip проверки.
|
||||
|
||||
Текущий пример: модуль общего формы в `upo_test/test2` имеет доказанный
|
||||
write-кодек saved-state; обнаруженный поток модуля отчёта — только
|
||||
read-only structural codec. Второй компонент может быть найден и прочитан,
|
||||
но не получит разрешение на SQL-запись, пока его служебный хвост не будет
|
||||
декодирован.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Модуль объекта отчёта в расширении
|
||||
|
||||
Статус: чтение, точное разрешение владельца и контролируемая запись короткого
|
||||
фрагмента поддержаны для доказанного hash-keyed saved-state маршрута.
|
||||
|
||||
Наблюдение в `upo_test`, расширение `test2`, отчёт `tt_Отчет`: сохранённый файл
|
||||
`<extension-guid>__<report-guid>.0` является `raw_deflate` контейнером из пяти
|
||||
потоков. BSL-модуль расположен в потоке `4`.
|
||||
|
||||
Поток начинается читаемым UTF-8-комментарием, но последующий текст содержит
|
||||
нулевые байты и смешанное представление символов. Общий потоковый декодер
|
||||
позволяет найти комментарий, однако его обратное кодирование меняет байты
|
||||
неизменённого хвоста BSL. Экспериментальная запись показала это в Конфигураторе
|
||||
и была немедленно восстановлена из парной резервной копии.
|
||||
|
||||
Правило: наличие читаемого BSL-фрагмента не доказывает возможность записи.
|
||||
Для потока с `NUL` адаптер возвращает
|
||||
`mixed_encoding_module_stream_unsupported` и не создаёт SQL-изменений.
|
||||
Это не означает, что для каждого отчёта нужен свой кодер: один доказанный
|
||||
кодек может обслуживать все модули с одинаковым физическим носителем.
|
||||
|
||||
Два ручных образца определили безопасную границу записи: редактируется только
|
||||
объявленный UTF-8-префикс, а непрозрачный хвост и остальные потоки сохраняются
|
||||
побайтно. Для hash-keyed overlay рабочий слой создаётся доказанным копированием
|
||||
подтверждённых ключей `ConfigCAS → ConfigCASSave`; `__configinfo` для него не
|
||||
создаётся и не предполагается.
|
||||
|
||||
Текущая реализация `parser.cas_payload.stream_blocks_with_data` ищет похожие
|
||||
заголовки регулярным выражением по всему распакованному буферу. В потоке
|
||||
отчёта такие последовательности встречаются и внутри данных, поэтому это
|
||||
эвристика для чтения, а не структурный декодер. Нельзя использовать её индекс
|
||||
потока как основание для обратной записи.
|
||||
|
||||
Структурный read-only декодер `decode_declared_utf8_bsl_prefix` подтверждён на
|
||||
этом образце: пять последовательных блоков; пятый имеет `declared_1 = 68` и
|
||||
`declared_2 = 512`. Первые 68 байт — UTF-8 BOM и точный BSL-текст, оставшиеся
|
||||
444 байта — непрозрачный служебный хвост. Декодер вернул только:
|
||||
`// protocol-report-baseline-1` и `// protocol-report-manual-change-4`.
|
||||
|
||||
## Пара ручных образцов `2 → 3`
|
||||
|
||||
Образцы `samples/manual-change-2.json` и `samples/manual-change-3.json`
|
||||
содержат raw-deflate байты, сохранённые человеком в Конфигураторе. В
|
||||
распакованном контейнере длиной 1283 байта замена цифры `2` на `3` изменила
|
||||
BSL ровно в смещении `838` (`0x32 → 0x33`). Одновременно платформа изменила
|
||||
шесть служебных диапазонов: `110..113`, `230..252`, `437..464`, `590..593`,
|
||||
`598..601`, `716..719`. Трёхбайтовое значение повторяется в нескольких
|
||||
местах, а два диапазона содержат связанные Base64-представления.
|
||||
|
||||
Это доказывает, что нельзя перепаковывать поток общим writer'ом. Отдельный
|
||||
fixed-width кодек меняет только первые `declared_1` байт: короткий текст
|
||||
дополняется пробелами внутри этого поля, хвост и размер члена не меняются.
|
||||
Рост префикса или структурная правка процедуры явно отклоняются.
|
||||
|
||||
## Полный объявленный поток: переменная длина
|
||||
|
||||
Нельзя переносить ограничение fixed-width с описанного выше носителя на все
|
||||
объектные BSL-модули. На рабочем маршруте `upo / фс_Отчеты /
|
||||
Report.УОП_ПечатьЦенниковАссортимента / .2 / stream:4` подтверждён другой
|
||||
контейнер: у выбранного BSL-потока `declared_1 == declared_2 == 36101` и
|
||||
`opaque_tail_bytes == 0`. Это полный UTF-8 поток, а не префикс перед
|
||||
непрозрачными данными.
|
||||
|
||||
Для такого носителя адаптер использует обычный структурный stream writer:
|
||||
он меняет текст, пересобирает оба объявленных размера в заголовке и сдвигает
|
||||
только последующие байты контейнера. Локальная обратная проверка целевой
|
||||
замены `НоваяСтрока.Выбран = Истина;` на более длинный фрагмент дала размер
|
||||
потока `36101 → 36198`, новый заголовок `36198/36198`, одно новое вхождение и
|
||||
нулевое старое. Все байты до заголовка выбранного потока сохранились.
|
||||
|
||||
Правило выбора кодека: fixed-width применяется **только** если доказан
|
||||
ненулевой непрозрачный хвост; если `declared_1 == declared_2` и хвоста нет,
|
||||
безопасна контролируемая замена переменной длины через структурный writer.
|
||||
Неизвестный или частично декодированный контейнер остаётся заблокированным,
|
||||
а не переводится в переменную длину по предположению.
|
||||
|
||||
## Правило публичного маршрута
|
||||
|
||||
Если объектный модуль состоит только из комментариев, это всё равно BSL-модуль:
|
||||
у него нет маркеров `Процедура`/`Функция`, но его наличие подтверждает
|
||||
структурный UTF-8-префикс в потоке. Адаптер обязан вернуть владельца и точный
|
||||
селектор чтения, не заставляя клиента искать поток. При записи он обязан
|
||||
использовать только fixed-width кодек, а не общий stream writer, который
|
||||
перезаписывает непрозрачный хвост. Парное обновление `__configinfo` допустимо
|
||||
только в отдельно подтверждённом каноническом layout.
|
||||
|
||||
## Повтор `code.write` после успешной записи
|
||||
|
||||
Повтор одного и того же публичного `code.write` не является новой операцией.
|
||||
До автоматической подготовки `ConfigCASSave` адаптер читает указанную
|
||||
процедуру в `effective_working`. Если старого фрагмента уже нет, а точный
|
||||
новый фрагмент присутствует ровно один раз в этой же процедуре, результат —
|
||||
`status: already_applied`, `applied: false`. В этом случае запрещены и
|
||||
подготовка saved-state, и новая SQL-запись.
|
||||
|
||||
Это правило предотвращает опасный путь: повторный запрос нельзя начинать с
|
||||
активного `ConfigCAS`, потому что его копирование способно заново построить
|
||||
рабочую копию из доизменённого источника и скрыть факт уже выполненной
|
||||
операции. Если оба фрагмента отсутствуют, новый фрагмент встречается
|
||||
несколько раз либо процедура не подтверждена, идемпотентность не
|
||||
предполагается: применяется обычная безопасная ошибка `not_found`/
|
||||
`ambiguous` или диагностика маршрута.
|
||||
|
||||
## Цепочка версий `2 → 3 → 4`
|
||||
|
||||
Третий live-SQL образец подтвердил повторяемую часть протокола. 20-байтовое
|
||||
Base64-поле в каждой новой версии равно SHA-1 сырого файла предыдущей версии:
|
||||
запись `3` хранит SHA-1 записи `2`, а запись `4` — SHA-1 записи `3`. Это
|
||||
доказанная ссылка версии, а не случайный текст. Его контрольный SHA-1:
|
||||
`fc84f0a9ef17034f8d82f44c5f9b07064864b524`.
|
||||
|
||||
Рядом расположен 16-байтовый токен, который меняется при каждом сохранении и
|
||||
дублируется фрагментами в трёх служебных местах. Алгоритм его создания не
|
||||
декодирован: адаптер его не генерирует и не изменяет. Его нельзя считать
|
||||
основанием для создания или изменения `__configinfo` в hash-keyed overlay.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema": "onec_report_module_stream_sample.v1",
|
||||
"origin": "Configurator manual save in upo_test/test2/tt_Отчет; no adapter write",
|
||||
"logical_change": "// protocol-report-manual-change-2",
|
||||
"physical_file_name": "<extension-guid>__<report-guid>.0",
|
||||
"compression": "raw_deflate",
|
||||
"raw_sha1": "89827a3c7fa07ae50a268d20a7cee34195d1d7a7",
|
||||
"raw_bytes": 618,
|
||||
"inflated_sha1": "6385a80972841eb8a97df43596ed15dd16966495",
|
||||
"inflated_bytes": 1283,
|
||||
"raw_base64": "7VLJbhNBEG2iPlnyBXHgBKMRByDTcc/S0zNCBtnEie2MvI2XwQJFPat3J/ZkV4TCgV/gCge+ggMfwE9w4MaBTzDtbLIFQYgzT+rq6q5+9apVNZvNXoMVAG6BCyQT+ByyIVw7NLyAkExs8idv+Zpx2kdO+7Zy4V/RFCxcOws0of/q4cvcCnj/+OzRfJ+jC0YgBOMlVRwKl+wl+o9Pn09USZawJIoSPrUyL5qqOQ39SafeLxxF5YlbpLvtDc9JJhMZfdUYD+L8gdHqWa2CWqNuX8aHekQ766GjlUqpIKodbzQcJ5M+lZKJE1MSZeK6MnMZ0onMkKYGKnJVipFHNdPHpoI9F4tSs1jp9OS6TTZ9u0GmTlVtDtrtmm9YuTTPYRjEoExHGtVVpOnYQKYcYkRNrCvU1TSdeaJU2S8PGo2jdaVXtVvTeF1TUsNi02rtVKp/mWONV7JVydojpRo6qtVNPW8acbe+29JLBWNvnoXyqjXKufwj/Dcm0RHzVA/JpumFLlYJ8xVROp760f6w5tml4n6l1GnQYSNbdFyl0MN/yHHd56vxUJbG46q/i31+AGwQgwkIAANDflrst2bc2G9dkgMiK4y5SP6H6Yq53iG3S2raTWqplLAzGcdjbzxAk2BnPImRy6bBoDsK5uq/CQ/ZaI8NkNdhoyhACtY9X2WUiFLZjywrzvhReJDP94ZRJqjn28pqPlcz0qdwjZcUg21wD2ZgGj6FCrcYlqHA/SfQgoIgfN2y30l3efzZ5R09f4dhlvuLvC1ILnkGvMNtGd7mUQJz8P4vTMLPBJrwe/YNl/jwxT4TBPAf5/gJ"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema": "onec_report_module_stream_sample.v1",
|
||||
"origin": "Configurator manual save in upo_test/test2/tt_Отчет; no adapter write",
|
||||
"logical_change": "// protocol-report-manual-change-3",
|
||||
"physical_file_name": "<extension-guid>__<report-guid>.0",
|
||||
"compression": "raw_deflate",
|
||||
"raw_sha1": "357c39a55e8f66c3efd8f7aac968a4db5d5eb5fb",
|
||||
"raw_bytes": 620,
|
||||
"inflated_sha1": "eb72cadfe12f68969cd278901a6f83e627ee1a53",
|
||||
"inflated_bytes": 1283,
|
||||
"raw_base64": "7VLLbhMxFDWVV5GyQSxYwWjEApRxa8+MxzOqAkrV0HQ6ah6TVyMQ8jyTNMmkybSlVBWCBb8AWxZ8BQs+gBV/wIIdCz4huE8lgiLEmiP5+trX555r3TubzV6CJQBugHNkM/gMxJSuHBadQ8pmNsSTN2LNBO2DoH1bOvcvaSqWrpw5mrT79P6T4hKofHn34HQ/RQ+MQASSBVUcSRfsBfqPj5+ONYUoWJFlBZ84hZ2mZk2jYNKt724exeWJZ7O9zmO/nc1mCkbOTAZp6dBs9Z3WplZj3i6h+lGQs8l2vZ0YNNnTUr5hr0/zJ0o2c2wpMqGeR7jHkUEJR7oWasjTGEY+060AWyr2PSwrTbvS7ZO6SzcCt0Gn7arWHHQ6tcB0inmRwzSpybiBdGZoSDewiSwSYcQsbKjM03WD+7JSOSgPGo2jdbVfdVvTdF1XV4Z202mNK9W/zLEsKunt2EallIvD/caWneB4bLJ+tdOuEcMXWZioWmeCKz4ifmNRA3Ff8xGxLD/ysEZ5oMrKi2kQHwxrvrttH1S2uw02bKzZbU/d7OM/5Ljq8+V4qAvjcdnf+T7fAy5IwQSEgIOhOM33Wzev7behkJASlXMPkX+YrlToPRd2QU2/Tm1lRRpPkjTxkwGahONkkiKPT8NBbxSeqv8mPOSjfT5AfpeP4hBp2PADjTMqK+Ugdpy0EMTRYanUH8aFsF7qqLlSsWbmT+CyKCkFz8AdWIB5+BCqwmJYhpLwV6EDJUn6uuW+VW6L+KOLO3b2DsM14c/ztiC94JnwlrBleFNEKSzCu78wqThTaMHva6+FxPvP7itJAv9xhp8="
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Configurator refresh behaviour
|
||||
|
||||
## Observed session model
|
||||
|
||||
`ConfigCASSave` is an SQL overlay, but writing it outside Configurator does not
|
||||
set Configurator’s in-memory changed/dirty state.
|
||||
|
||||
Observed consequences:
|
||||
|
||||
| State before adapter write | Minimal human action after write |
|
||||
|---|---|
|
||||
| Object already existed in saved-state | Close and reopen that object |
|
||||
| Adapter created the first pending object for an extension | Close and reopen the extension |
|
||||
| Adapter created the first pending object for base configuration | Close and reopen the configuration |
|
||||
|
||||
If the user manually edits and saves any object in an extension, Configurator
|
||||
marks the extension changed; reopening another object can then load its
|
||||
`ConfigCASSave` overlay.
|
||||
|
||||
## Required adapter response
|
||||
|
||||
Write results should return machine-readable guidance:
|
||||
|
||||
```json
|
||||
{
|
||||
"configurator_refresh": {
|
||||
"required": true,
|
||||
"scope": "object|extension|configuration",
|
||||
"action": "close_reopen_object|close_reopen_extension|close_reopen_configuration"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This guidance does not claim that the adapter controls Configurator; it merely
|
||||
reports the minimum observed reload boundary.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Known dead ends and prohibited shortcuts
|
||||
|
||||
## Do not repeat
|
||||
|
||||
- Do not write only a module `.0` row: `__configinfo` then references stale
|
||||
bytes and activation fails.
|
||||
- Do not use random, zeroed, copied-from-unrelated, or guessed HashVersion
|
||||
service atoms. A controlled random test produced a licensing/crypto error.
|
||||
- Do not write `Config` or `ConfigCAS` directly, even in tests.
|
||||
- Do not use a suffix such as `.0` as proof of “object module”.
|
||||
- Do not infer an object name from a GUI tree label, table suffix, or an
|
||||
approximate Russian name.
|
||||
- Do not overwrite all saved-state rows when adding a delta: preserve user
|
||||
work in other objects and every map entry.
|
||||
- Do not treat an open Configurator screen as SQL evidence.
|
||||
|
||||
## Open questions
|
||||
|
||||
- The generation algorithm and ownership of the 32-byte `__configinfo`
|
||||
service atom are unknown. Preservation is proven for the studied flow;
|
||||
generation is not.
|
||||
- Multi-part storage streams need a dedicated row-layout codec.
|
||||
- The extension protocol must be reproduced on another object class and a
|
||||
second platform build before being declared generally supported.
|
||||
- Configurator’s in-memory dirty-state implementation is inferred from
|
||||
behaviour, not decoded from platform source. Only the reload guidance is
|
||||
operationally relied upon.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Contract for an agent using the 1C SQL adapter
|
||||
|
||||
The agent is a consumer of evidence returned by the adapter. It must never
|
||||
turn a plausible interpretation into a fact.
|
||||
|
||||
## Fundamental adapter rule
|
||||
|
||||
The adapter is a **SQL codec**, not an expert system for 1C. It reads and
|
||||
writes only according to the versioned configuration-storage specification
|
||||
that has been decoded from live SQL and recorded in this knowledge base.
|
||||
It may expose a semantic name only when that mapping is proven by the decoder.
|
||||
For an unknown carrier, field, child object, byte range, checksum, or service
|
||||
atom, the only valid result is `unsupported`, `partial`, or `ambiguous` with
|
||||
the observed evidence. It must not synthesize a structure, BSL, or value to
|
||||
make an operation appear complete.
|
||||
|
||||
## Required behaviour
|
||||
|
||||
- Start from a public 1C name/ref supplied by the user.
|
||||
- Ask the adapter to resolve the live route; internal GUIDs, SQL numbers and
|
||||
file names remain adapter implementation details.
|
||||
- Use public `code.read`, `code.search`, and `code.write` for normal BSL work.
|
||||
Never pass or request `ConfigCAS`, `ConfigCASSave`, a payload hash, a
|
||||
canonical saved filename, or a stream index. Those are diagnostic evidence,
|
||||
not an agent-facing selector contract.
|
||||
- Read the target bytes before proposing any edit.
|
||||
- Quote the exact proven fragment, its count, and the selected layer.
|
||||
- For a write, require a plan/preflight and retain the returned rollback and
|
||||
refresh guidance.
|
||||
- State `unknown`, `ambiguous`, or `protocol_incomplete` when evidence is
|
||||
absent. Ask for a larger fragment or a human Configurator action instead of
|
||||
guessing.
|
||||
|
||||
## Forbidden behaviour
|
||||
|
||||
- Invent BSL procedures, form controls, field paths, joins, storage tables,
|
||||
module streams, extension ownership, or `__configinfo` atoms.
|
||||
- Claim that a SQL saved-state edit is active before active-layer verification.
|
||||
- Claim a repository lock merely because an adapter request was recorded.
|
||||
- Tell the user that the Configurator UI has refreshed unless the required
|
||||
close/reopen boundary was completed by the human.
|
||||
- Write directly to active configuration or application tables.
|
||||
- Work around a public-route failure by retrying against an internal module
|
||||
reference. Report the public `not_found`, `ambiguous`, `unsupported`, or
|
||||
`protocol_incomplete` result so the adapter can be corrected.
|
||||
|
||||
## Write-result language
|
||||
|
||||
Use the adapter’s `configurator_refresh` object verbatim in human-facing
|
||||
instructions. Do not collapse `object`, `extension`, and `configuration` into
|
||||
the same generic “restart” advice.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Evidence and safety rules
|
||||
|
||||
## Evidence levels
|
||||
|
||||
| Level | Meaning | Permitted use |
|
||||
|---|---|---|
|
||||
| Observed | Read from live SQL once | Diagnosis only |
|
||||
| Reproduced | Seen in independent before/after saves | Decoder rule candidate |
|
||||
| Round-trip proven | Decode → unchanged encode returns identical bytes | Safe read/transform component |
|
||||
| Activation proven | A human Configurator applied it and active SQL verified it | Controlled `upo_test` writer component |
|
||||
|
||||
No rule may be promoted because a name, suffix, or payload shape “looks right”.
|
||||
|
||||
## Codec boundary
|
||||
|
||||
The configuration-storage specification is the adapter's sole authority for
|
||||
decoding and encoding. A writer is enabled only when the relevant version of
|
||||
that specification defines every changed byte and every dependent integrity
|
||||
atom, and round-trip evidence proves the encoder. Any remaining opaque atom is
|
||||
preserved byte-for-byte; if a requested edit requires changing it, the write is
|
||||
unsupported until the specification is extended by a controlled experiment.
|
||||
|
||||
## Allowed mutation boundary
|
||||
|
||||
- The adapter runtime uses SQL only.
|
||||
- `upo_test` may receive controlled writes to `ConfigSave` and
|
||||
`ConfigCASSave` only.
|
||||
- `Config`, `ConfigCAS`, and application data are never direct write targets.
|
||||
- A write must have a live target resolution, optimistic SHA-1 precondition,
|
||||
reversible evidence, atomic transaction, and readback verification.
|
||||
- Repository coordination is a separate policy; an adapter marker is not a
|
||||
native repository lock.
|
||||
|
||||
## Exact edits
|
||||
|
||||
For BSL fragment replacement, provide one of:
|
||||
|
||||
- an old fragment occurring exactly once;
|
||||
- a larger unique surrounding fragment;
|
||||
- a proven structural container path/offset plus original SHA-1.
|
||||
|
||||
If a fragment occurs zero or more than once, do not choose arbitrarily. Return
|
||||
the count and candidate contexts. Deletion follows the same rule.
|
||||
|
||||
## What an agent must report
|
||||
|
||||
Every proposal and applied result must state:
|
||||
|
||||
- active and saved layers used;
|
||||
- public target and SQL evidence retained internally;
|
||||
- original and new SHA-1 values;
|
||||
- paired files changed;
|
||||
- exact-match count;
|
||||
- rollback reference;
|
||||
- Configurator refresh guidance.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Protocol experiment record template
|
||||
|
||||
Create one file per material experiment under `experiments/`.
|
||||
|
||||
```markdown
|
||||
# <date> — <short target and action>
|
||||
|
||||
## Scope
|
||||
- Base and classification:
|
||||
- Extension/object public names:
|
||||
- Runtime/platform build:
|
||||
- Authority for mutation:
|
||||
|
||||
## Before
|
||||
- Active source table/key/SHA-1:
|
||||
- Saved-state files and SHA-1:
|
||||
- Relevant descriptor/map entries:
|
||||
|
||||
## Exact action
|
||||
- Public target resolution evidence:
|
||||
- Old fragment / structural selector and occurrence count:
|
||||
- Payload codec and round-trip result:
|
||||
- Paired files written in one transaction:
|
||||
|
||||
## After SQL evidence
|
||||
- Readback SHA-1 values:
|
||||
- Map/reference validation:
|
||||
- Unrelated pending files preserved:
|
||||
|
||||
## Human Configurator verification
|
||||
- Reload action:
|
||||
- Visible result:
|
||||
- Apply result:
|
||||
- Active/saved postcondition:
|
||||
|
||||
## Outcome
|
||||
- Proven fact:
|
||||
- Failed hypothesis:
|
||||
- Remaining unknown:
|
||||
- Follow-up regression fixture/test:
|
||||
```
|
||||
@@ -0,0 +1,121 @@
|
||||
# 1C Adapter: universal write dispatcher and typed handlers
|
||||
|
||||
## Decision
|
||||
|
||||
The public write contract stays universal and name-first:
|
||||
|
||||
```text
|
||||
code.write / metadata.write
|
||||
-> resolve public object and extension layer
|
||||
-> plan and gates
|
||||
-> select one typed handler
|
||||
-> prepare saved state internally when required
|
||||
-> apply, SQL-readback, rollback evidence
|
||||
```
|
||||
|
||||
The caller never selects an SQL table, saved file, stream, payload codec, or
|
||||
handler. If no handler has a proven capability for the requested object type
|
||||
and operation, the dispatcher returns `unsupported_write_target` with a public
|
||||
explanation. It must not fall back to a generic byte rewrite.
|
||||
|
||||
## Current state
|
||||
|
||||
The behaviour is already logically separated, but is physically concentrated
|
||||
in `plugins/1c/connector/adapter_1c_server.py` (about 3.5 MB). The main
|
||||
dispatcher is `metadata_write` and currently branches to:
|
||||
|
||||
| Public target | Existing internal writer |
|
||||
|---|---|
|
||||
| BSL module | `metadata_module_write_apply` |
|
||||
| BSL embedded in a managed form | `form_embedded_module_handler_write_apply` |
|
||||
| Form element/property | `metadata_form_element_write_apply` |
|
||||
| Form command/button caption | `metadata_form_command_button_write` |
|
||||
| Scalar object/member property | `metadata_object_property_write` |
|
||||
| Add object member | `metadata_object_member_add` |
|
||||
| Scheduled-job schedule | `metadata_scheduled_job_schedule_write` |
|
||||
|
||||
This is a suitable functional base. The problem is coupling: routing,
|
||||
saved-state preparation, result shaping, codecs, SQL writes, and HTTP/RPC
|
||||
dispatch live in one module, so a change in one type is too likely to affect
|
||||
another.
|
||||
|
||||
## Target module layout
|
||||
|
||||
```text
|
||||
plugins/1c/connector/
|
||||
adapter_1c_server.py # HTTP, RPC registration, composition root only
|
||||
write/
|
||||
contracts.py # WriteIntent, WritePlan, WriteResult, capability errors
|
||||
dispatcher.py # universal metadata.write dispatch; no SQL codecs
|
||||
gates.py # layer, repository, optimistic-hash and mode gates
|
||||
saved_state.py # Config→ConfigSave / ConfigCAS→ConfigCASSave prepare + receipt rollback
|
||||
registry.py # handler registration and deterministic selection
|
||||
handlers/
|
||||
module.py
|
||||
embedded_form_module.py
|
||||
form_element.py
|
||||
form_command.py
|
||||
object_property.py
|
||||
object_member.py
|
||||
scheduled_job.py
|
||||
unsupported.py
|
||||
storage/
|
||||
sql_saved_state.py # transactions, guarded row copy, backup/readback
|
||||
extension_routes.py # active-to-saved route and cache refresh
|
||||
```
|
||||
|
||||
`parser/` remains the place for pure payload decoding/encoding. A handler may
|
||||
use a parser codec only where its round-trip proof exists; SQL access is
|
||||
provided through a narrow context rather than imported globals.
|
||||
|
||||
## Handler contract
|
||||
|
||||
Each handler implements the same four operations:
|
||||
|
||||
1. `can_handle(intent, evidence) -> supported | unsupported | ambiguous`.
|
||||
2. `plan(intent, evidence) -> WritePlan` with exact guards and no mutation.
|
||||
3. `apply(plan, context) -> WriteResult` only after shared gates succeed.
|
||||
4. `rollback(result, context)` when the handler created reversible state.
|
||||
|
||||
The dispatcher selects exactly one handler. Zero handlers yields
|
||||
`unsupported_write_target`; multiple handlers yield `ambiguous_write_handler`.
|
||||
Handlers never select another extension layer after dispatch.
|
||||
|
||||
`metadata.write.capabilities` also returns `registered_handlers`. This makes
|
||||
the runtime registry visible beside the broader capability matrix and prevents
|
||||
an API claim from silently drifting away from the installed handlers.
|
||||
|
||||
## Invariants owned centrally
|
||||
|
||||
- public name/ref and extension scope resolve before handler selection;
|
||||
- only saved layers are writable;
|
||||
- saved-state preparation is internal and idempotent;
|
||||
- optimistic hash, audit event, backup/receipt, SQL readback and rollback
|
||||
policy are common infrastructure;
|
||||
- SQL readback is not described as Configurator activation;
|
||||
- low-level storage fields are redacted from name-first responses.
|
||||
|
||||
## Safe migration order
|
||||
|
||||
1. **Completed:** add storage-free `write/contracts.py`, typed handler
|
||||
declarations under `write/handlers/`, and `write/registry.py`; connect
|
||||
`metadata.write` to the registry while delegating to existing writers
|
||||
unchanged. The registry has a deny-by-default result for unknown target
|
||||
kinds.
|
||||
2. **In progress:** add `write/context.py`; the scheduled-job route now enters
|
||||
its typed handler through this explicit context. The handler still delegates
|
||||
to the single existing implementation until its body moves in one change.
|
||||
3. Move `saved_state.py` and `storage/sql_saved_state.py` first. The recently
|
||||
proven hash-keyed extension prepare/rollback smoke is its acceptance test.
|
||||
4. Extract the least coupled handlers: scheduled job, object property, object
|
||||
member.
|
||||
5. Extract form element and form command handlers.
|
||||
6. Extract module and embedded-form-module handlers last; retain their exact
|
||||
payload codec and paired-write tests.
|
||||
7. Reduce `metadata_write` to validation, plan/gate orchestration and one
|
||||
registry call. Keep the old public API names and response schema intact.
|
||||
|
||||
Do not split by copying code into parallel paths. Each extraction must move one
|
||||
authoritative implementation, keep the existing tests green, and add one
|
||||
handler-level `plan → apply_and_rollback → readback` test in `upo_test` when a
|
||||
matching fixture exists.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Аналитика адаптера 1С: передача проекта
|
||||
|
||||
Дата актуализации: 2026-08-07.
|
||||
|
||||
## Назначение
|
||||
|
||||
`adapter-observer` — независимый read-only веб-интерфейс аналитики для SQL-only адаптера 1С. Он читает журналы REST и MCP, получает разрешённые снимки метаданных через публичный API адаптера и не имеет прямого доступа к SQL-базе 1С.
|
||||
|
||||
Сервис не является зависимостью `adapter-1c-rest` или `adapter-1c-mcp`: остановка либо обновление observer не должна влиять на работу адаптера.
|
||||
|
||||
## Описание адаптера 1С
|
||||
|
||||
Адаптер 1С — SQL-only сервис для чтения метаданных и выполнения строго контролируемых операций с saved-state конфигурации 1С. Он работает через подтверждённые структуры SQL-хранилища; не запускает Configurator и не должен выдумывать метаданные, маршруты или двоичные payload.
|
||||
|
||||
Основные части:
|
||||
|
||||
- `adapter-1c-rest` — REST API адаптера;
|
||||
- `adapter-1c-mcp` — MCP-шлюз, который вызывает REST API;
|
||||
- `adapter-1c-audit` — аудит-контур REST;
|
||||
- `adapter-observer` — независимая аналитика журналов и разрешённых read-only вызовов.
|
||||
|
||||
Код адаптера находится в текущем репозитории:
|
||||
|
||||
```text
|
||||
plugins/1c/connector/adapter_1c_server.py реализация REST/RPC методов
|
||||
plugins/1c/connector/contracts/openapi.yaml публичный HTTP-контракт
|
||||
plugins/1c/mcp/adapter_1c_mcp.py MCP-шлюз
|
||||
plugins/1c/observer/ аналитика адаптера
|
||||
```
|
||||
|
||||
### Где развёрнут адаптер
|
||||
|
||||
| Контур | REST | MCP | Docker-хост | Назначение |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Production / внешний | `http://docker.cin.su:8011` | `http://docker.cin.su:8021/mcp` | `docker.cin.su` | Основной внешний адаптер и observer. |
|
||||
| Staging / тестовый | `http://docker-test.cin.su:8011` при наличии тестового стека | `http://docker-test.cin.su:8021/mcp` при наличии тестового стека | `test-docker` | Проверка перед production. |
|
||||
| Изолированная тестовая база | `base_id=upo_test` | через соответствующий MCP | выбранный контур | Разрешены контролируемые тесты и rollback. |
|
||||
|
||||
Production-контейнеры на `docker.cin.su`:
|
||||
|
||||
```text
|
||||
adapter-1c-rest порт 8011
|
||||
adapter-1c-mcp порт 8021
|
||||
adapter-1c-audit внутренний аудит REST
|
||||
adapter-observer порт 8031
|
||||
```
|
||||
|
||||
## Как проверять новые функции адаптера
|
||||
|
||||
### До развёртывания
|
||||
|
||||
1. Изменить реализацию в `plugins/1c/connector/adapter_1c_server.py` и зафиксировать публичный контракт в `plugins/1c/connector/contracts/openapi.yaml`.
|
||||
2. Добавить либо обновить unit/smoke-тест в `tests/1c/` или `scripts/smoke_1c_*.py`.
|
||||
3. Для новой метадаты или SQL-маршрута сначала получить доказательства из live SQL в `upo_test`; при неполном codec вернуть `unsupported`/`protocol_incomplete`, а не предполагать данные.
|
||||
4. Прогнать тесты и smoke-проверку на `upo_test`.
|
||||
|
||||
### Staging-проверка
|
||||
|
||||
Развернуть обновлённые `adapter-1c-rest` и при необходимости `adapter-1c-mcp` на `test-docker`. Не использовать staging вместо external MCP production без явного запроса.
|
||||
|
||||
Проверять новый read-only метод через REST/RPC с явным `base_id=upo_test`. Проверка write-маршрута должна пройти обязательные plan/preflight/apply/rollback-gates и не даёт права заявлять, что Configurator принял или активировал изменение.
|
||||
|
||||
Полезные проверки:
|
||||
|
||||
```powershell
|
||||
# Контейнеры и порты выбранного контура
|
||||
docker --host ssh://test-docker ps --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}'
|
||||
|
||||
# Контракт/доступные методы на REST
|
||||
Invoke-WebRequest -UseBasicParsing http://docker-test.cin.su:8011/methods
|
||||
|
||||
# Health observer после тестового вызова
|
||||
Invoke-WebRequest -UseBasicParsing http://docker-test.cin.su:8031/health
|
||||
```
|
||||
|
||||
Пути и аргументы нового метода нельзя составлять по догадке: использовать только его документированный контракт и подтверждённые публичные селекторы.
|
||||
|
||||
## Как обновлять аналитику вместе с адаптером
|
||||
|
||||
Каждое изменение адаптера нужно оценивать как изменение наблюдаемого контракта.
|
||||
|
||||
| Изменение адаптера | Что изменить в observer |
|
||||
| --- | --- |
|
||||
| Новый метод аудита или новый статус | Проверить `event_view`, фильтр статусов, группировку summary и русские подписи. |
|
||||
| Новый read-only метод для объекта | Добавить его в жёсткий allowlist `/api/object/action` только после проверки публичного селектора и безопасного ответа. |
|
||||
| Новый тип метаданных | Добавить его в `treeGroups`, если он должен быть виден в дереве. |
|
||||
| Новое поле длительности | Оставить в API машинское значение, а в UI провести через `duration()`. |
|
||||
| Изменение схемы audit JSONL | Сохранить обратную совместимость: неизвестные поля показывать только в деталях, отсутствующие поля считать необязательными. |
|
||||
| Новый write-маршрут | Не добавлять кнопку выполнения в observer. Допустимо отобразить только подтверждённую capability/статус после отдельного проектного решения. |
|
||||
|
||||
Обязательная последовательность релиза:
|
||||
|
||||
1. Сначала обновить адаптер и проверить его новый метод на `upo_test`.
|
||||
2. Убедиться, что REST/MCP audit содержит безопасную запись вызова без SQL, BSL, payload и секретов.
|
||||
3. Обновить observer в staging; открыть новый сценарий в UI и проверить, что метод не классифицируется как `exception` ошибочно.
|
||||
4. Обновить observer на production вместе с совместимой версией адаптера.
|
||||
5. Проверить `/health`, журнал, аналитику и конкретный объект в дереве.
|
||||
|
||||
Observer не должен требовать одновременный рестарт адаптера. При выпуске только frontend/observer достаточно пересоздать `adapter-observer`; REST и MCP остаются запущенными.
|
||||
|
||||
## Что сделано
|
||||
|
||||
### Интерфейс
|
||||
|
||||
- Журнал REST-запросов с фильтрами по методу, базе, статусу, периоду и минимальной длительности.
|
||||
- Аналитика p50/p95, медленных методов, исключений и ожидаемых безопасных отказов.
|
||||
- Корреляция MCP ↔ REST по `request_id`.
|
||||
- Дерево метаданных конфигурации с разделом «Справочники».
|
||||
- Для каждого доступного справочника отображаются read-only действия:
|
||||
- Карточка;
|
||||
- Свойства;
|
||||
- Реквизиты;
|
||||
- Формы;
|
||||
- Команды;
|
||||
- Модули;
|
||||
- Макеты;
|
||||
- Связи.
|
||||
- Результат действия открывается в диалоге с названием операции, объектом, статусом и длительностью.
|
||||
- Поиск по уже загруженному списку справочников и счётчик `Показано: N из M`.
|
||||
|
||||
### Время выполнения
|
||||
|
||||
- Во всех пользовательских представлениях миллисекунды форматируются в секунды, минуты и часы.
|
||||
- Фильтр минимальной длительности вводится в секундах.
|
||||
- В технических API-полях сохраняется `duration_ms`: это контрактное машинное значение, не пользовательская подпись.
|
||||
|
||||
### Производительность
|
||||
|
||||
- Observer отдаёт до 1000 объектов за один запрос к `metadata.objects.list`.
|
||||
- Для базы `upo` загружается 798 доступных справочников из 825 объектов одного типа одним запросом; 27 объектов скрыты адаптером как отсутствующие/нечитаемые.
|
||||
- Проверенное время live-сканирования этого списка: около 18,7 секунды. Это время адаптера и SQL-чтения, а не рендеринга кнопок в браузере.
|
||||
|
||||
### Безопасность
|
||||
|
||||
- Observer вызывает только жёстко заданный allowlist read-only методов для строки справочника.
|
||||
- Новые действия не выполняют запись, подготовку saved-state, активацию конфигурации или операции Configurator.
|
||||
- В интерфейсе не восстанавливаются исторические запросы из audit JSONL.
|
||||
|
||||
## Что ещё нужно сделать
|
||||
|
||||
Приоритетный следующий этап:
|
||||
|
||||
1. Добавить быстрый серверный поиск справочника по имени, чтобы не ожидать полное live-сканирование при работе с одним объектом.
|
||||
2. Добавить отображение прогресса при загрузке больших разделов: число прочитанных объектов, текущая страница и время ожидания.
|
||||
3. Вынести перечень разрешённых действий и русские названия в отдельную конфигурацию/контракт, а не хранить в фронтенд-коде.
|
||||
4. Добавить компактные пользовательские карточки результатов действий вместо показа полного JSON; JSON сохранить как диагностическую вкладку.
|
||||
5. Добавить тесты UI/HTTP для сценария: открыть «Справочники» → загрузить → увидеть кнопки → выполнить «Карточка».
|
||||
6. Добавить version/release marker в `/health` и UI, чтобы быстро отличать старую Docker-сборку от актуальной.
|
||||
7. Добавить снимки и сравнение аналитики между релизами: список методов, покрытие метаданных, p50/p95 и изменения ошибок.
|
||||
8. До публикации вне внутренней сети определить аутентификацию, роли, срок хранения audit-данных и экспортируемые поля.
|
||||
|
||||
Не реализовывать без отдельного разрешения:
|
||||
|
||||
- повтор исторических write/activation/repository-запросов;
|
||||
- прямое подключение observer к SQL 1С;
|
||||
- запуск или автоматизацию Configurator;
|
||||
- вывод BSL-текста, SQL-полей, паролей или ключей из журналов.
|
||||
|
||||
## Исходные файлы
|
||||
|
||||
```text
|
||||
plugins/1c/observer/
|
||||
observer_server.py HTTP API и безопасный allowlist действий
|
||||
web/index.html оболочка интерфейса
|
||||
web/assets/app.js UI, форматирование времени, дерево, действия
|
||||
web/assets/style.css стили
|
||||
Dockerfile образ observer
|
||||
|
||||
core/deploy/docker/adapter-observer/
|
||||
compose.yaml отдельный Docker Compose стек
|
||||
.env.example пример runtime-переменных
|
||||
|
||||
docs/runbooks/adapter-observer.md
|
||||
эксплуатационный контракт и ограничения
|
||||
```
|
||||
|
||||
## Docker-развёртывание
|
||||
|
||||
### Текущий production-хост
|
||||
|
||||
- Docker host: `docker.cin.su`.
|
||||
- Контейнер: `adapter-observer`.
|
||||
- URL: `http://docker.cin.su:8031/`.
|
||||
- Образ: `adapter-observer:latest`.
|
||||
- Порт: `8031`.
|
||||
- Внешняя сеть адаптера: `adapter-1c_default`.
|
||||
- Read-only тома журналов:
|
||||
- `adapter-1c_adapter-1c-data` → `/audit:ro`;
|
||||
- `adapter-1c-mcp_adapter-1c-mcp-data` → `/mcp-audit:ro`.
|
||||
- Собственный state-том: `adapter-observer_adapter-observer-state`.
|
||||
|
||||
### Команда обновления
|
||||
|
||||
Из корня текущего репозитория:
|
||||
|
||||
```powershell
|
||||
$env:DOCKER_HOST = 'ssh://docker.cin.su'
|
||||
docker compose `
|
||||
--project-directory 'Z:\codex\LLM\core\deploy\docker\adapter-observer' `
|
||||
-f 'Z:\codex\LLM\core\deploy\docker\adapter-observer\compose.yaml' `
|
||||
up -d --build adapter-observer
|
||||
```
|
||||
|
||||
После обновления:
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest -UseBasicParsing http://docker.cin.su:8031/health
|
||||
docker --host ssh://docker.cin.su ps --filter 'name=^/adapter-observer$'
|
||||
```
|
||||
|
||||
Команда пересоздаёт только `adapter-observer`. Не запускать `docker compose down` в проектах REST/MCP адаптера и не перезапускать `adapter-1c-rest` или `adapter-1c-mcp` ради обновления аналитики.
|
||||
|
||||
### Тестовый хост
|
||||
|
||||
Для staging используется тот же стек с `DOCKER_HOST='ssh://test-docker'` и URL `http://docker-test.cin.su:8031/`.
|
||||
|
||||
## Проверки после переноса
|
||||
|
||||
1. `GET /health` возвращает `status: ok` и показывает файлы REST/MCP audit.
|
||||
2. Открыть «Дерево объектов» и загрузить `upo`.
|
||||
3. Нажать «Читать» у «Справочники»: блок должен остаться раскрытым.
|
||||
4. Убедиться, что видна строка вида `798 объектов · N с` без единицы `мс`.
|
||||
5. У первой строки должны быть восемь кнопок действий.
|
||||
6. Нажать «Карточка»: открывается диалог с успешным статусом и читаемой длительностью.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Проверка векторного поиска по коду 1С
|
||||
|
||||
Дата проверки: 2026-07-26. База: `upo_test`.
|
||||
|
||||
## Контур
|
||||
|
||||
- актуальный SQL-derived индекс адаптера: 1 080 модулей, 22 538 chunks;
|
||||
- очередь изменений: 0, snapshot чистый;
|
||||
- модель: `Qwen3-Embedding-0.6B-GGUF`, `Q8_0`;
|
||||
- runtime: `llama.cpp`, CPU-only на Ryzen 9 5900X;
|
||||
- endpoint: `http://docker-gpu.cin.su:8082/v1/embeddings`;
|
||||
- вектор модели: 1024, сохраняемый Matryoshka-срез: 384;
|
||||
- RTX 4090 сервисом эмбеддингов не используется.
|
||||
|
||||
## Измерения
|
||||
|
||||
Прогретый короткий запрос к endpoint: 0,03–0,08 с. Индексация 100 процедур и
|
||||
функций длиной до 1 000 символов заняла 54,52 с, ошибок и конфликтов нет.
|
||||
|
||||
Контрольные запросы к строгому поиску:
|
||||
|
||||
| Запрос | Top-1 | Время |
|
||||
|---|---|---:|
|
||||
| проверить является ли объект документом | `ЭтоДокумент` | 0,70 с |
|
||||
| преобразовать число из строки | `ЧислоИзСтроки` | 0,44 с |
|
||||
| добавить реквизит в HTML представление | `ДобавитьРеквизитКHTML` | 0,50 с |
|
||||
|
||||
Каждый результат был повторно проверен по live SQL. Чтение `read_selector`
|
||||
вернуло точное тело `ЭтоДокумент`, а не сохранённый текст из векторного кеша.
|
||||
|
||||
## Решение по отдельной векторной БД
|
||||
|
||||
Пока не добавлять. SQLite остаётся достаточным для текущего инкрементального
|
||||
контура и проще связывает `chunk_id`, `text_sha1`, outbox и проверку
|
||||
актуальности. Векторный кеш не является источником истины.
|
||||
|
||||
Повторно оценить ANN-хранилище после заполнения не менее 5 000 актуальных
|
||||
384-мерных vectors. Практические триггеры:
|
||||
|
||||
- p95 строгого поиска выше 1 секунды;
|
||||
- более 50 000 актуальных chunks;
|
||||
- SQLite-файл адаптера больше 1 ГиБ из-за embeddings.
|
||||
|
||||
Если триггер сработает, Qdrant/pgvector должен быть только производной копией:
|
||||
ключ `chunk_id`, обязательный `text_sha1`, namespace базы/модели/размерности,
|
||||
удаление через outbox. Перед ответом адаптер всё равно проверяет live SQL.
|
||||
|
||||
## Выявленный следующий приоритет
|
||||
|
||||
Глобально найденные активные модули пока часто имеют `object_ref=null`.
|
||||
Безопасный `read_selector` и имя процедуры присутствуют, но публичное имя
|
||||
владельца 1С не восстановлено. Это не проблема вектора; это неполный
|
||||
`metadata.module_owner_cache`. Следующая доработка — фоновое построение
|
||||
name-first карты владельцев и lazy backfill только для top-кандидатов.
|
||||
@@ -1,7 +1,8 @@
|
||||
# 1C Agent Coding Contract
|
||||
|
||||
This contract is the default rule set for coding agents that work through the
|
||||
1C adapter.
|
||||
1C adapter. The agent calls it only through the `onec_request` MCP tool; the
|
||||
REST SQL adapter is MCP's private downstream transport.
|
||||
|
||||
## Default View
|
||||
|
||||
@@ -13,6 +14,57 @@ This contract is the default rule set for coding agents that work through the
|
||||
- Objects can exist only in saved-state and can later be activated or canceled.
|
||||
Do not hide them from the agent view.
|
||||
|
||||
### Empty saved-state layer
|
||||
|
||||
An empty saved-state layer is normal before the first edit. In that case an
|
||||
agent still uses only public selectors and reports the adapter result; it must
|
||||
not obtain an active `ConfigCAS`/`Config` module reference and retry by hand.
|
||||
|
||||
Known adapter limitation (2026-08-02): for an extension-owned object module
|
||||
with no prepared saved-state row, public `code.read` can return
|
||||
`source_missing` even though the active module is proven to exist. Treat this
|
||||
as an adapter defect, not as evidence that the 1C object or BSL is absent.
|
||||
Use a developer-owned diagnostic check to investigate it; do not expose its
|
||||
storage coordinates to a coding agent.
|
||||
|
||||
## Required adapter acceptance fixture
|
||||
|
||||
`upo_test` must contain one isolated extension-owned `Report` with a decoded
|
||||
`object_module`, a unique BSL anchor, and no shared business role. This fixture
|
||||
is created and maintained by a human in Configurator; the SQL-only adapter must
|
||||
not fabricate it. It is the required target for the public acceptance sequence:
|
||||
|
||||
```text
|
||||
code.read(ref, extension, module_ordinal=1)
|
||||
→ code.write(old, new, apply_and_rollback)
|
||||
→ code.search verifies new text
|
||||
→ rollback and saved-state cleanup
|
||||
```
|
||||
|
||||
The request must contain no `layer`, table, file name, module ref, payload hash,
|
||||
or stream index. Until the fixture exists, unit tests prove routing only; they
|
||||
do not prove a live extension Report write.
|
||||
|
||||
### Verified base-module smoke
|
||||
|
||||
On 2026-08-02 the public sequence was verified on `upo_test` against base
|
||||
`Report.АвтоматическиеСкидки`, `module_ordinal=1`: a unique comment replacement
|
||||
completed in about 25 seconds with `verified_and_rolled_back`. The adapter
|
||||
auto-prepared `ConfigSave`, read back the write, rolled back the BSL change,
|
||||
then removed the exact rows it had prepared. The public result contains the
|
||||
opaque `prepare_receipt_id` and `prepare_cleanup`; the final saved-state status
|
||||
was `empty`. This validates the base route only, not the extension Report route.
|
||||
Live check on 2026-08-02 found 19 extensions but zero extension-owned Reports
|
||||
in `upo_test`; therefore the required extension acceptance fixture is currently
|
||||
missing and the extension write branch remains unaccepted.
|
||||
|
||||
Live check on 2026-08-13 found an extension BSL stream for
|
||||
`Report.УОП_ИнвентаризационнаяОпись`, but its `role_status=unconfirmed`.
|
||||
That is read-only evidence, not an acceptance fixture: do not write through an
|
||||
ordinal, storage reference, or guessed Configurator role. A human must add or
|
||||
identify one extension Report with a decoded `object_module` before the live
|
||||
extension `apply_and_rollback` test can run.
|
||||
|
||||
## Compare Views
|
||||
|
||||
- Use `state=both` or `source_state=all` only when the task needs a comparison
|
||||
@@ -33,6 +85,34 @@ Use public names and selectors:
|
||||
3. `code.read` with `state=working` to read the module or routine.
|
||||
4. `code.read` with `state=both` only for an explicit saved-vs-active check.
|
||||
|
||||
For a report form, keep the descriptions separate:
|
||||
|
||||
1. `metadata.object.forms(ref=Report.<name>, source_state=working)` lists the
|
||||
report's form references.
|
||||
2. `metadata.object.form.details` with that same public report ref and the
|
||||
returned form name reads the form description (attributes, parameters,
|
||||
commands, items, and the form module).
|
||||
3. `metadata.form.decode(view=structure)` is an optional compact static
|
||||
projection. It must retain `unresolved` parent/child links where no SQL
|
||||
codec has proved them.
|
||||
|
||||
A form command does not have its own module. Its handler is a named routine in
|
||||
the form module only when the decoder returns an evidenced command link.
|
||||
|
||||
Use the `read_selector.selector_token` returned by discovery with the method
|
||||
declared in that selector. Do not copy GUIDs, module refs, table names, or file
|
||||
names into a follow-up request. A module ordinal, display name, or storage-derived role
|
||||
is not a Configurator-tree path. If a result has `role_status=unconfirmed` or
|
||||
`configurator_path_status=unconfirmed`, the agent must not name, write, or
|
||||
infer its tree owner; report the missing decoder evidence instead.
|
||||
|
||||
When checking object commands, call `metadata.object.commands` with the same
|
||||
public `ref`/`kind`+`name` and `extension` selector. The adapter resolves the
|
||||
extension name and reads its active metadata itself. A prior `not_found` from a
|
||||
route that did not carry the extension context is not evidence that the report
|
||||
has no commands. Conversely, a BSL stream suffix alone is never evidence of a
|
||||
command or of its module path.
|
||||
|
||||
Agents should ask for and report object names, routine names, and code text.
|
||||
They should not ask users for SQL tables, storage file names, stream indexes, or
|
||||
saved-state write flags during normal coding work.
|
||||
@@ -52,12 +132,52 @@ Supported public edit shapes:
|
||||
say "save this code" and send the desired code text. It must not ask whether SQL
|
||||
saved-state apply flags are allowed.
|
||||
|
||||
For extension object modules, preparation maps the active payload route to its
|
||||
canonical saved-state filename internally. A public `code.write` therefore
|
||||
continues with `extension`, `ref`, module role, and the proven replacement
|
||||
only; it must never ask the caller to supply the canonical filename or stream.
|
||||
|
||||
Every successful `code.write` response must show:
|
||||
|
||||
- `write_mode.target=saved_state`;
|
||||
- `write_mode.activation_state=not_activated`;
|
||||
- `write_mode.production_apply=false`.
|
||||
|
||||
### Repository-controlled extension writes
|
||||
|
||||
For a repository-controlled extension, use the public sequence below and stop
|
||||
when it asks for a human Configurator action:
|
||||
|
||||
```text
|
||||
code.search(extension, ref, old fragment)
|
||||
→ repository.lock.plan(extension, ref)
|
||||
→ repository.lock.request
|
||||
→ human captures the exact object in Configurator
|
||||
→ repository.lock.confirm
|
||||
→ code.write(extension, ref, module_ordinal, old, new, repository_lock)
|
||||
→ code.search readback
|
||||
```
|
||||
|
||||
The lock plan for `upo / фс_Отчеты1 /
|
||||
Report.фс_ПродовольственнаяКорзина` resolves exactly one object:
|
||||
`Отчет.фс_ПродовольственнаяКорзина`, in extension layer
|
||||
`extension:8e02accd-8a34-11f1-8294-005056b0d483`. SQL cannot prove a native
|
||||
Configurator capture; only a human confirmation can continue this route.
|
||||
|
||||
`metadata.write.preflight` currently plans canonical high-level
|
||||
`metadata.write` targets and may return `needs_route` for this compatibility
|
||||
`code.write` shape before saved-state preparation. This is not permission to
|
||||
use its low-level payload hint. Use `code.write(mode=plan)` instead: a first
|
||||
extension edit may return public `needs_prepare`, which means to confirm the
|
||||
repository lock and reissue the same public request in an apply mode. Keep
|
||||
using the public `code.write` route; report any different route failure to
|
||||
adapter developers.
|
||||
|
||||
If it returns `extension_saved_state_prepare_protocol_unproven`, do not retry
|
||||
or supply technical coordinates. The adapter has not yet proven the exact
|
||||
saved-state preparation codec for this extension layout; report it for adapter
|
||||
development. Do not infer that the extension was not saved in Configurator.
|
||||
|
||||
## Hidden Storage Details
|
||||
|
||||
The form module container marker `///----` is adapter-owned storage syntax.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Актуальный векторный поиск по коду 1С
|
||||
|
||||
Цель: семантический поиск по локальному SQL-индексу адаптера без потери
|
||||
актуальности кода. Вектор является только производным кешем: перед выдачей
|
||||
адаптер сверяет найденные фрагменты с текущим состоянием конфигурации и
|
||||
отбрасывает либо переиндексирует устаревшие записи.
|
||||
|
||||
## Выбранная модель
|
||||
|
||||
- `Qwen/Qwen3-Embedding-0.6B-GGUF`, квантование `Q8_0`;
|
||||
- OpenAI-compatible endpoint на `http://docker-gpu.cin.su:8082`;
|
||||
- `llama.cpp`, `--embedding --pooling last`;
|
||||
- CPU-only (`--n-gpu-layers 0`), чтобы не менять работающие GPU-сервисы;
|
||||
- endpoint возвращает 1024 измерения, клиент использует Matryoshka-срез до
|
||||
запрошенных 384 измерений и повторно нормализует его.
|
||||
|
||||
Образ `llama.cpp` зафиксирован digest, а модель — официальным repository/quant
|
||||
селектором. Это исключает незаметную смену runtime при повторном deploy.
|
||||
|
||||
Модель и образ публичные, endpoint работает в изолированном тестовом контуре
|
||||
без токена. Постоянный кеш модели хранится вне git в
|
||||
`Z:\LLM\models\cache\llama.cpp`.
|
||||
|
||||
## Развёртывание
|
||||
|
||||
Проверить итоговую конфигурацию:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy_embeddings.ps1 -ConfigOnly
|
||||
```
|
||||
|
||||
Запустить сервис с загрузкой образа:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy_embeddings.ps1 -Pull
|
||||
```
|
||||
|
||||
Первый запуск скачивает модель в постоянный кеш и поэтому может занять
|
||||
несколько минут. Скрипт ждёт `/health`, проверяет имя модели и делает реальный
|
||||
запрос к `/v1/embeddings`.
|
||||
|
||||
## Обновление векторов
|
||||
|
||||
Сначала адаптер должен содержать актуальные текстовые chunks. Затем worker
|
||||
забирает только отсутствующие либо изменившиеся фрагменты:
|
||||
|
||||
```powershell
|
||||
python scripts/embed_1c_code_vectors.py `
|
||||
--adapter-url http://docker.cin.su:8011/rpc `
|
||||
--base-id upo_test `
|
||||
--embedding-provider openai-compatible `
|
||||
--embedding-model qwen3-embedding-0.6b `
|
||||
--dimensions 384 `
|
||||
--embedding-base-url http://docker-gpu.cin.su:8082 `
|
||||
--limit 500 `
|
||||
--batch-size 8 `
|
||||
--chunk-kind routine `
|
||||
--max-text-chars 4000 `
|
||||
--json
|
||||
```
|
||||
|
||||
Worker по умолчанию индексирует `routine`: процедуры и функции дают наиболее
|
||||
точный контекст для программирования и заметно быстрее пересчитываются при
|
||||
частых изменениях. Для диагностического покрытия модульных фрагментов можно
|
||||
добавить второй `--chunk-kind module`; это более дорогой отдельный проход.
|
||||
`--max-text-chars` не обрезает код молча: длинные chunks пропускаются в этом
|
||||
проходе и остаются pending. Их нужно разбивать на окна отдельной задачей либо
|
||||
индексировать в период низкой нагрузки с большим лимитом.
|
||||
|
||||
Размерность входит в имя кеша (`openai-compatible:qwen3-embedding-0.6b@d384`),
|
||||
поэтому векторы разных размеров никогда не смешиваются.
|
||||
|
||||
## Поиск
|
||||
|
||||
```powershell
|
||||
python scripts/search_1c_code_vectors.py `
|
||||
"где рассчитывается сумма документа перед проведением" `
|
||||
--adapter-url http://docker.cin.su:8011/rpc `
|
||||
--base-id upo_test `
|
||||
--embedding-provider openai-compatible `
|
||||
--embedding-model qwen3-embedding-0.6b `
|
||||
--dimensions 384 `
|
||||
--embedding-base-url http://docker-gpu.cin.su:8082 `
|
||||
--limit 10 `
|
||||
--json
|
||||
```
|
||||
|
||||
Поиск вызывается с `strict=true` и `verify=true`. Сохранённые изменения
|
||||
Конфигуратора перекрывают активную конфигурацию, а удалённые/изменённые chunks
|
||||
не возвращаются по старому вектору.
|
||||
|
||||
Для активного кода расширений хеш `ConfigCAS` разрешается через текущий
|
||||
manifest расширения в descriptor объекта. Результат содержит обычные
|
||||
`kind/name`, а также `extension` и `extension_guid`; вызывающему коду не нужно
|
||||
работать с CAS-хешами как с именами объектов.
|
||||
|
||||
Перед первым глобальным поиском после обновления адаптера нужно постранично
|
||||
заполнить локальную карту владельцев:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "metadata.module_owner_cache.backfill",
|
||||
"payload": {
|
||||
"base_id": "upo_test",
|
||||
"limit": 50,
|
||||
"kind_index": 0,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Следующий вызов получает `kind_index` и `offset` из `next_cursor`. Повторять до
|
||||
`complete=true`. Операция читает актуальные метаданные, но пишет только в
|
||||
локальный SQLite адаптера. Найденные имена сразу добавляются в существующие
|
||||
строки лексического и векторного индексов; перестраивать embeddings не нужно.
|
||||
По умолчанию объекты без строк code index быстро пропускаются. `deep=true`
|
||||
нужен только для отдельного фонового заполнения владельцев неиндексированных
|
||||
модулей и не должен использоваться в интерактивном поиске.
|
||||
|
||||
Для `Qwen3-Embedding` клиент автоматически добавляет к векторизуемому запросу
|
||||
англоязычную инструкцию поиска по исходному коду 1С, как рекомендует карточка
|
||||
модели. В `query` адаптера остаётся исходный русский текст, поэтому лексическая
|
||||
часть гибридного поиска не загрязняется служебным префиксом.
|
||||
|
||||
## Отдельная векторная БД
|
||||
|
||||
На текущем этапе не требуется. Векторы хранятся рядом с индексом адаптера в
|
||||
SQLite и выбираются линейным сканированием. Это проще и гарантирует атомарную
|
||||
проверку актуальности. Отдельный ANN-движок имеет смысл только после замера
|
||||
десятков тысяч актуальных chunks и неприемлемой задержки; источником истины всё
|
||||
равно остаётся 1С/SQL, а ANN должен хранить `chunk_id` и `text_sha1` как
|
||||
проверяемую производную копию.
|
||||
@@ -6,7 +6,7 @@ form decoder reached zero missing items and zero mismatches.
|
||||
## Target
|
||||
|
||||
- Base: `upo_test`
|
||||
- Adapter: `http://docker-gpu.cin.su:8011`
|
||||
- Adapter: `http://docker.cin.su:8011`
|
||||
- Saved-state table: `ConfigCASSave`
|
||||
- Form payload file:
|
||||
`f96a0c45-3eff-11f1-8287-005056b0d483__25c39fbf-35a4-4b43-8e3f-cd1f91082c88.0`
|
||||
@@ -104,7 +104,7 @@ payload = {
|
||||
"timeout_seconds": 60,
|
||||
"max_items": 5000
|
||||
}
|
||||
req = urllib.request.Request("http://docker-gpu.cin.su:8011/rpc", data=json.dumps({"method":"metadata.write_learning.capture_after","payload":payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"})
|
||||
req = urllib.request.Request("http://docker.cin.su:8011/rpc", data=json.dumps({"method":"metadata.write_learning.capture_after","payload":payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"})
|
||||
print(urllib.request.urlopen(req, timeout=90).read().decode("utf-8"))
|
||||
'@ | python -
|
||||
```
|
||||
@@ -116,7 +116,7 @@ Then run:
|
||||
import json, urllib.request
|
||||
for method in ("metadata.write_learning.diff", "metadata.write_learning.infer_rule"):
|
||||
payload = {"learning_id": "form-command-binding-standard-customize-form"}
|
||||
req = urllib.request.Request("http://docker-gpu.cin.su:8011/rpc", data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"})
|
||||
req = urllib.request.Request("http://docker.cin.su:8011/rpc", data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"})
|
||||
print(urllib.request.urlopen(req, timeout=90).read().decode("utf-8"))
|
||||
'@ | python -
|
||||
```
|
||||
@@ -151,7 +151,7 @@ payload = {
|
||||
"timeout_seconds": 60,
|
||||
"max_items": 5000
|
||||
}
|
||||
req = urllib.request.Request("http://docker-gpu.cin.su:8011/rpc", data=json.dumps({"method":"metadata.write_learning.capture_after","payload":payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"})
|
||||
req = urllib.request.Request("http://docker.cin.su:8011/rpc", data=json.dumps({"method":"metadata.write_learning.capture_after","payload":payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"})
|
||||
print(urllib.request.urlopen(req, timeout=90).read().decode("utf-8"))
|
||||
'@ | python -
|
||||
```
|
||||
@@ -163,7 +163,7 @@ Then run:
|
||||
import json, urllib.request
|
||||
for method in ("metadata.write_learning.diff", "metadata.write_learning.infer_rule"):
|
||||
payload = {"learning_id": "form-command-binding-local-apply-command"}
|
||||
req = urllib.request.Request("http://docker-gpu.cin.su:8011/rpc", data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"})
|
||||
req = urllib.request.Request("http://docker.cin.su:8011/rpc", data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"})
|
||||
print(urllib.request.urlopen(req, timeout=90).read().decode("utf-8"))
|
||||
'@ | python -
|
||||
```
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 1C Form Discovery And Editing
|
||||
|
||||
The semantic object/form boundary for agent responses is defined in
|
||||
[`1c-metadata-structure.md`](1c-metadata-structure.md). In particular, an
|
||||
owner returns references to forms; form parameters, attributes, commands,
|
||||
elements, and form-module facts are read through the form's own public
|
||||
selector. Do not duplicate a decoded form as an invented subtree of its owner.
|
||||
|
||||
This runbook adapts the MOXCEL discovery loop to managed forms. The goal is a
|
||||
full SQL-side form decoder and safe saved-state editing through the test
|
||||
extension, with XML exports used only as evidence fixtures.
|
||||
@@ -7,7 +13,7 @@ extension, with XML exports used only as evidence fixtures.
|
||||
## Current Baseline
|
||||
|
||||
- Default base: `upo_test`.
|
||||
- Default adapter endpoint: `http://docker-gpu.cin.su:8011`.
|
||||
- Default adapter endpoint: `http://docker.cin.su:8011`.
|
||||
- Primary test extension/object fixture:
|
||||
`фс_ДоработкиОбщее` /
|
||||
`DataProcessor.фс_НастройкаУсловногоОформления`.
|
||||
@@ -142,7 +148,7 @@ Decode a concrete saved-state form payload:
|
||||
|
||||
```powershell
|
||||
python scripts/smoke_1c_write_matrix.py `
|
||||
--base-url http://docker-gpu.cin.su:8011 `
|
||||
--base-url http://docker.cin.su:8011 `
|
||||
--base-id upo_test `
|
||||
--table ConfigCASSave `
|
||||
--file-name <form-file-name> `
|
||||
@@ -177,7 +183,7 @@ Build a decoder coverage and gap profile for an object form:
|
||||
|
||||
```powershell
|
||||
python scripts/profile_1c_forms.py `
|
||||
--adapter-url http://docker-gpu.cin.su:8011 `
|
||||
--adapter-url http://docker.cin.su:8011 `
|
||||
--base-id upo_test `
|
||||
--kind Catalog `
|
||||
--name ЗадачиАссистентаУправления `
|
||||
@@ -223,7 +229,7 @@ Run the existing source-aware route smoke:
|
||||
|
||||
```powershell
|
||||
python scripts/smoke_1c_saved_state_write_routes.py `
|
||||
--base-url http://docker-gpu.cin.su:8011 `
|
||||
--base-url http://docker.cin.su:8011 `
|
||||
--base-id upo_test `
|
||||
--table ConfigCASSave `
|
||||
--file-name <form-file-name> `
|
||||
@@ -234,7 +240,7 @@ Then run the matrix smoke:
|
||||
|
||||
```powershell
|
||||
python scripts/smoke_1c_write_matrix.py `
|
||||
--base-url http://docker-gpu.cin.su:8011 `
|
||||
--base-url http://docker.cin.su:8011 `
|
||||
--base-id upo_test `
|
||||
--table ConfigCASSave `
|
||||
--file-name <form-file-name> `
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Структура метаданных конфигурации 1С
|
||||
|
||||
Этот документ задаёт модель, которой должны следовать агенты и публичные
|
||||
ответы SQL-адаптера. Он описывает семантические метаданные, а не физические
|
||||
имена SQL-записей, пути контейнеров или номера потоков.
|
||||
|
||||
## Главное правило
|
||||
|
||||
У конфигурации нет единого шаблона дочерних узлов для всех классов объектов.
|
||||
Набор узлов определяется классом объекта и подтверждается двумя источниками:
|
||||
|
||||
1. документацией платформы 1С для соответствующего класса;
|
||||
2. живым чтением выбранного объекта через адаптер.
|
||||
|
||||
Не добавляйте в ответ ни табличную часть, ни форму, ни модуль только потому,
|
||||
что они есть у другого объекта. Если живой декодер не подтвердил конкретный
|
||||
дочерний объект, верните явный неполный результат, а не пустой вымышленный
|
||||
узел.
|
||||
|
||||
## Связь объекта и формы
|
||||
|
||||
Форма — самостоятельный объект метаданных, на который владелец ссылается в
|
||||
своей коллекции `Формы`. В кратком описании владельца возвращается только
|
||||
ссылка/идентичность формы: имя, GUID и доступный публичный селектор. Полное
|
||||
описание формы читается отдельным запросом.
|
||||
|
||||
```text
|
||||
Отчет
|
||||
└─ Формы
|
||||
└─ <ссылка на Форму>
|
||||
|
||||
Форма
|
||||
├─ Реквизиты
|
||||
├─ Параметры
|
||||
├─ Команды
|
||||
├─ Модуль формы
|
||||
└─ Элементы
|
||||
```
|
||||
|
||||
Параметры формы не являются её реквизитами. Это декларация параметризации при
|
||||
открытии формы. Команда формы не имеет отдельного модуля: её имя обработчика
|
||||
является свойством команды и разрешается в модуле формы. Аналогично имя
|
||||
обработчика события элемента — свойство элемента, а не дочерний узел дерева.
|
||||
|
||||
Подчинённая команда владельца (`Отчет.Команды.<Имя>`) отличается от команды
|
||||
формы. Если документация и живая база подтверждают модуль команды, его надо
|
||||
сообщать как свойство этой команды, не выдавая за форму или за модуль формы.
|
||||
|
||||
## Отчёт
|
||||
|
||||
Официальная методическая документация 1С подтверждает, что отчёт может иметь
|
||||
реквизиты и табличные части. Табличная часть, в свою очередь, имеет реквизиты.
|
||||
Для отчёта допустима следующая *классовая* схема; конкретные экземпляры
|
||||
показываются только после живого чтения:
|
||||
|
||||
```text
|
||||
Отчет
|
||||
├─ Реквизиты
|
||||
├─ Табличные части
|
||||
│ └─ Реквизиты табличной части
|
||||
├─ Формы → отдельные описания Форм
|
||||
├─ Команды
|
||||
├─ Макеты
|
||||
├─ Модуль объекта
|
||||
└─ Модуль менеджера
|
||||
```
|
||||
|
||||
СКД — это тип/содержимое макета, а не обязательная отдельная ветвь любого
|
||||
отчёта. Не создавайте узел СКД, если в живом объекте не подтверждён
|
||||
соответствующий макет.
|
||||
|
||||
## Публичные маршруты адаптера
|
||||
|
||||
Для объекта `Отчет.<Имя>` адаптер использует следующие раздельные операции:
|
||||
|
||||
1. `metadata.object.forms` — читает подтверждённые ссылки отчёта на формы;
|
||||
возвращаемые строки содержат идентичность формы, а не её элементы,
|
||||
реквизиты, параметры или текст модуля.
|
||||
2. `metadata.object.form.details` — по владельцу и имени формы получает
|
||||
развёрнутое описание формы.
|
||||
3. `metadata.form.decode` — декодирует одну конкретную форму и возвращает её
|
||||
параметры, реквизиты, элементы, команды, события и сводку встроенного
|
||||
модуля.
|
||||
|
||||
Передавайте `configuration_view=effective_working` либо не передавайте view:
|
||||
MCP установит этот режим сам. Он означает логическую рабочую конфигурацию с
|
||||
учётом базовой конфигурации, сохранённых изменений и расширений; он не
|
||||
разрешает агенту выбирать `ConfigSave` или `ConfigCASSave`.
|
||||
|
||||
Таким образом, ссылка `Отчет → Форма` не должна заменяться копией описания
|
||||
формы внутри объекта отчёта. Агрегированный `metadata.object.full` удобен для
|
||||
обзора, но для работы с формой агент обязан сохранять её отдельный публичный
|
||||
селектор и при необходимости вызвать один из двух form-методов выше.
|
||||
|
||||
Текущая реализация подтверждает маршрут для `Report`: в
|
||||
`RELATED_SECTION_RULES` форма имеет категорию `Form`, а
|
||||
`metadata.object.forms` возвращает только `guid`, `name`, `synonyms` и счётчики
|
||||
частей. Детали добавляет только `metadata.object.form.details` через отдельный
|
||||
вызов `metadata.form.decode`.
|
||||
|
||||
## Источники
|
||||
|
||||
- [Иерархия объектов конфигурации и подчинённые объекты — 1С:EDT](https://its.1c.ru/db/content/edtdoc/src/topics/i030.html)
|
||||
- [Отчёты, реквизиты и табличные части — методическая поддержка 1С](https://its.1c.ru/db/content/metod8dev/src/platform81/startersdev/i8102579.htm)
|
||||
- [Формы: реквизиты, параметры, команды и модуль — руководство разработчика 1С](https://its.1c.ru/db/v8310doc/bookmark/dev/TI000000391)
|
||||
- [Явное объявление параметров формы — стандарт 1С](https://its.1c.ru/db/content/v8std/src/1%C2%A0200/700/i8100741.htm)
|
||||
|
||||
## Проверка перед изменением
|
||||
|
||||
Перед тем как менять форму или модуль, агент обязан выполнить публичное
|
||||
чтение владельца, затем получить форму её собственным селектором. Отсутствие
|
||||
SQL-подключения, нераспознанный контейнер или неоднозначная связь — это
|
||||
`unresolved`/`protocol_incomplete`, а не разрешение достроить структуру по
|
||||
документации.
|
||||
|
||||
`metadata.form.decode` с `view=structure` возвращает компактные доказанные
|
||||
факты по статическим элементам. Пока SQL-кодек логического родителя не
|
||||
доказан, поля `parent` и `children` имеют статус `unresolved`; нельзя строить
|
||||
их из технической глубины или пути сериализованного контейнера.
|
||||
|
||||
Запросы `runtime.form.elements.inspect` и `runtime.form.inspect` возвращают
|
||||
`runtime_inspection_unsupported`. SQL-адаптер не открывает формы, не запускает
|
||||
обработчики и не выдаёт созданные СКД во время работы элементы за статические
|
||||
метаданные.
|
||||
@@ -240,7 +240,7 @@ changes:
|
||||
|
||||
```powershell
|
||||
python scripts\profile_1c_tabular_templates.py `
|
||||
--adapter-url http://docker-gpu.cin.su:8011 `
|
||||
--adapter-url http://docker.cin.su:8011 `
|
||||
--base-id upo_test `
|
||||
--inventory-json reports\1c-template-baselines\upo_test_configuration_tabular_templates.json `
|
||||
--output-json reports\1c-template-baselines\upo_test_tabular_template_profiles.json `
|
||||
|
||||
@@ -116,8 +116,11 @@ SQL удобен как быстрый источник данных, но не
|
||||
canonical path до процедуры, областью является эта процедура/функция; иначе
|
||||
весь текущий saved-модуль.
|
||||
|
||||
По умолчанию `code.write` делает `mode=apply`, но это apply в saved-state
|
||||
слой (`ConfigSave`/`ConfigCASSave`), а не применение конфигурации в runtime.
|
||||
По умолчанию `code.write` делает безопасный `mode=plan` и не пишет в SQL.
|
||||
Только явно переданный `mode=apply`, `apply_and_verify` или
|
||||
`apply_and_rollback` может записать saved-state слой
|
||||
(`ConfigSave`/`ConfigCASSave`); это всё равно не применение конфигурации в
|
||||
runtime.
|
||||
Адаптер сам выставляет save-first gates и сам выбирает физический маршрут.
|
||||
Физические детали возвращаются только при `include_storage=true` для
|
||||
диагностики. Ответ `code.write` всегда содержит `write_mode`: target
|
||||
@@ -134,6 +137,62 @@ source `saved_state`, activation_state `not_activated`.
|
||||
добирается отдельным проходом, а `counts.saved_matches` и
|
||||
`counts.active_matches` показывают покрытие по слоям.
|
||||
|
||||
### Repository lock and first extension edit
|
||||
|
||||
Для изменения объекта в расширении, подключенном к хранилищу, агент сначала
|
||||
использует только публичные вызовы:
|
||||
|
||||
```text
|
||||
code.search → repository.lock.plan → repository.lock.request
|
||||
→ человек захватывает объект в Конфигураторе → repository.lock.confirm
|
||||
→ code.write → code.search (readback)
|
||||
```
|
||||
|
||||
Если у extension-модуля ещё нет saved-state строки, `code.write(mode=plan)`
|
||||
возвращает `needs_prepare` и
|
||||
`diagnostics.next_action=confirm_repository_lock_then_apply`. Это нормальный
|
||||
первый-edit маршрут: после подтверждённого lock тот же публичный `code.write`
|
||||
в apply-режиме сам подготовит saved-state. Агент не передаёт `module_ref`,
|
||||
`stream_index`, таблицу или имя технического файла.
|
||||
|
||||
Исключение: `extension_saved_state_prepare_protocol_unproven` означает, что
|
||||
автоматическая подготовка запрещена. Это не доказательство того, что
|
||||
расширение не сохраняли: адаптер ещё не доказал точный prepare-кодек для
|
||||
наблюдаемой extension-layout. Агент не создаёт контейнер через SQL и передаёт
|
||||
случай разработчикам адаптера без технических координат.
|
||||
|
||||
Acceptance extension write считается пройденным только при наличии в
|
||||
`upo_test` отдельного extension-owned `Report` с object module и успешном
|
||||
публичном `code.write(..., apply_and_rollback)` без storage-координат. Успех
|
||||
base-модуля в `ConfigSave` не доказывает ветку `ConfigCAS → ConfigCASSave`.
|
||||
|
||||
`upo` не используется для автоматических проверочных записей. Контролируемые
|
||||
`apply_and_rollback` проверки разрешены только в `upo_test`.
|
||||
|
||||
Для регрессии первого extension-edit используйте публичный smoke (без SQL
|
||||
таблиц, key, module_ref или GUID в запросе):
|
||||
|
||||
```powershell
|
||||
python scripts\smoke_1c_extension_saved_state_prepare.py --apply
|
||||
```
|
||||
|
||||
Он проверяет `plan → prepare/readback → rollback → immediate plan` на
|
||||
`upo_test / фс_ДоработкиОбщее / Catalog.Номенклатура`. После rollback не
|
||||
должно остаться saved-state строк, а повторный план должен быть `plan_ready`.
|
||||
|
||||
### Safe adapter deployment
|
||||
|
||||
Перед Docker-обновлением скрипт развёртывания запрашивает `/health` и ждёт
|
||||
`runtime.state=ready` и `runtime.active_rpc_count=0`. При остановке REST
|
||||
переходит в `draining`; уже начатые запросы продолжают выполняться до пяти
|
||||
минут. Не используйте `-SkipDrainCheck`, кроме аварийного случая, когда
|
||||
ответственный подтвердил отсутствие активной записи.
|
||||
|
||||
После обновления проверяются REST `/health?base_id=upo_test` и MCP `/health`.
|
||||
JSONL-аудит REST хранится в `/data/adapter-audit.jsonl`, MCP — в
|
||||
`/data/mcp-audit.jsonl`; оба периодически сворачиваются в
|
||||
`/data/adapter-audit-reports/latest.json` на соответствующем хосте.
|
||||
|
||||
Если фрагмент повторяется, агент должен передать более узкий контекст
|
||||
(`routine_name`) или заменить процедуру целиком. Адаптер в такой ситуации
|
||||
возвращает `ambiguous_fragment`, `scope` и `counts.occurrences`, и не
|
||||
|
||||
@@ -52,7 +52,7 @@ MCP server configuration.
|
||||
The MCP proxy does not hard-code the 1C adapter address. Pass it with:
|
||||
|
||||
```text
|
||||
ONEC_ADAPTER_URL=http://docker-gpu.cin.su:8011
|
||||
ONEC_ADAPTER_URL=http://docker.cin.su:8011
|
||||
```
|
||||
|
||||
Optional adapter bearer token:
|
||||
@@ -75,6 +75,37 @@ core/deploy/docker/adapter-1c-mcp/.env.example
|
||||
|
||||
For real deployment, create a non-committed `.env` next to the compose file and set the actual adapter URL/token there.
|
||||
|
||||
## REST adapter on docker.cin.su
|
||||
|
||||
The REST SQL adapter is deployed from:
|
||||
|
||||
```text
|
||||
core/deploy/docker/adapter-1c/compose.yaml
|
||||
```
|
||||
|
||||
Its published port is set in a non-committed REST environment file:
|
||||
|
||||
```text
|
||||
ADAPTER_1C_HOST_PORT=8011
|
||||
```
|
||||
|
||||
SQL-base administration is available in the REST adapter at:
|
||||
|
||||
```text
|
||||
http://docker.cin.su:<ADAPTER_1C_HOST_PORT>/admin
|
||||
```
|
||||
|
||||
With the default port this is `http://docker.cin.su:8011/admin`. The page is
|
||||
used to add or update SQL connections for named bases. Do not put connection
|
||||
passwords in Git or documentation. If the port changes, use the new port both
|
||||
for this page and in `ONEC_ADAPTER_URL`.
|
||||
|
||||
If that port is occupied, select a free port there and set the MCP URL to the
|
||||
same value, for example `ONEC_ADAPTER_URL=http://docker.cin.su:18011`.
|
||||
Keep `ONEC_SQL_BASES_JSON` (or the mounted `/data/onec-sql-bases.json`) only
|
||||
in that external runtime configuration; never place SQL passwords in this
|
||||
repository.
|
||||
|
||||
## Deploy
|
||||
|
||||
Deploy both REST adapter and MCP proxy, then run live verification when a test
|
||||
@@ -83,6 +114,8 @@ base is available:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass `
|
||||
-File scripts\deploy_1c_adapter_stack.ps1 `
|
||||
-RestEnvFile <path-to-non-committed-rest-env> `
|
||||
-McpEnvFile <path-to-non-committed-mcp-env> `
|
||||
-BaseId <base-id-from-project-context>
|
||||
```
|
||||
|
||||
@@ -692,7 +725,7 @@ Saved-state BSL write smoke:
|
||||
|
||||
```powershell
|
||||
python scripts\smoke_1c_code_write_saved_state.py `
|
||||
--adapter-url http://docker-gpu.cin.su:8011 `
|
||||
--adapter-url http://docker.cin.su:8011 `
|
||||
--base-id <base-id-from-project-context> `
|
||||
--extension <extension-name> `
|
||||
--object-type CommonForm `
|
||||
@@ -712,7 +745,7 @@ Agent working-view report:
|
||||
|
||||
```powershell
|
||||
python scripts\report_1c_agent_working_view.py `
|
||||
--adapter-url http://docker-gpu.cin.su:8011 `
|
||||
--adapter-url http://docker.cin.su:8011 `
|
||||
--base-id <base-id-from-project-context> `
|
||||
--extension <extension-name> `
|
||||
--object-type CommonForm `
|
||||
@@ -738,7 +771,7 @@ Optional live selector-chain smoke against a real adapter/base:
|
||||
python scripts\smoke_1c_mcp_selector_chain.py `
|
||||
--live `
|
||||
--transport rest `
|
||||
--adapter-url http://docker-gpu.cin.su:8011 `
|
||||
--adapter-url http://docker.cin.su:8011 `
|
||||
--base-id <base-id-from-project-context> `
|
||||
--json `
|
||||
--no-report
|
||||
@@ -776,7 +809,19 @@ Use `-SavedStateTable ConfigSave` or `-SavedStateTable ConfigCASSave` to choose
|
||||
which save-layer table is used by the copy plan and saved-state write smokes.
|
||||
Add `-RequireSelectorChainWritePlanComposition` when the selected base/object
|
||||
must have a saved-state stream that lets the selector-chain smoke compose a
|
||||
concrete read-only `metadata.write.plan`.
|
||||
concrete read-only `metadata.write.plan`. The same strict mode also requires
|
||||
the write-preflight smoke to discover both an extension form and an extension
|
||||
module by public names (`extension/ref/form/member` and
|
||||
`extension/ref/form/module/stream_ordinal`), compose allowed plans, and prove
|
||||
that the repository and support gates use the same resolved
|
||||
`extension:<GUID>` layer. Module search restores the public extension name from
|
||||
`ConfigCASSave`; it never returns the storage GUID as the caller-facing
|
||||
selector. The smoke then submits each public selector with a deliberately
|
||||
different GUID and requires a read-only
|
||||
`blocked / extension_selector_conflict` result, so a legacy permissive
|
||||
repository profile cannot authorize a mismatched layer. Run both checks
|
||||
directly with
|
||||
`scripts/smoke_1c_write_preflight.py --require-name-first-extension-form --require-name-first-extension-module`.
|
||||
|
||||
To exercise the MCP proxy itself, switch transport and URL:
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Adapter Observer
|
||||
|
||||
`adapter-observer` is an independent, read-only web service for adapter telemetry.
|
||||
It must never connect to 1C SQL, call write methods, modify `/data`, or be a
|
||||
runtime dependency of `adapter-1c-rest` or `adapter-1c-mcp`.
|
||||
|
||||
## Inputs and guarantees
|
||||
|
||||
- It mounts REST and MCP Docker volumes at `/audit:ro` and `/mcp-audit:ro` and
|
||||
reads only rotated `adapter-audit.jsonl*` / `mcp-audit.jsonl*` files.
|
||||
- The source events contain public selector summaries, status/error, request
|
||||
correlation id, timings, and safe result summaries. They intentionally omit
|
||||
BSL text, raw SQL payloads, storage keys, and credentials.
|
||||
- A missing or malformed log is an Observer condition, never an adapter error.
|
||||
- The UI distinguishes `exception` from expected safe outcomes such as
|
||||
`blocked`, `unsupported`, and `invalid_argument`.
|
||||
|
||||
## Tabs
|
||||
|
||||
- **Журнал запросов**: filterable individual REST events and their safe details.
|
||||
- **Аналитика**: p50/p95, slow method ranking, grouped non-OK fingerprints and
|
||||
evidence-based next actions.
|
||||
- **Покрытие**: contract for adding a future `metadata.adapter.audit` snapshot.
|
||||
|
||||
## Retry boundary
|
||||
|
||||
Audit JSONL deliberately has no complete request payload. Do not reconstruct
|
||||
or guess it from selector summaries. A future replay button may repeat only a
|
||||
request captured by Observer itself with an explicit read-only allowlist. It
|
||||
must never replay a write request from logs.
|
||||
|
||||
## Deployment
|
||||
|
||||
On `docker.cin.su`, inspect the existing REST container and volume first:
|
||||
|
||||
```text
|
||||
docker inspect adapter-1c-rest
|
||||
docker volume ls
|
||||
```
|
||||
|
||||
Deploy only the Observer stack from `core/deploy/docker/adapter-observer`.
|
||||
It uses external volume `adapter-1c_adapter-1c-data` read-only and port 8031
|
||||
by default. Do not run `down` against the adapter compose project.
|
||||
|
||||
```text
|
||||
docker compose --env-file .env -f compose.yaml up -d --build
|
||||
curl http://localhost:8031/health
|
||||
```
|
||||
|
||||
## Development contract
|
||||
|
||||
When adapter telemetry changes, preserve backwards parsing: unknown fields are
|
||||
shown in event details; known metrics remain optional. Before adding a special
|
||||
visualization, record its input schema and add fixture JSONL tests. The general
|
||||
journal must continue working for unknown adapter methods.
|
||||
|
||||
## Key decisions for future agents
|
||||
|
||||
1. Keep Observer a separate Compose project, port, image and failure domain.
|
||||
Never add it as a dependency to REST/MCP and never restart those containers
|
||||
while deploying it.
|
||||
2. The volumes are externally named `adapter-1c_adapter-1c-data` and
|
||||
`adapter-1c-mcp_adapter-1c-mcp-data` on `docker.cin.su`; Observer mounts
|
||||
them only as `/audit:ro` and `/mcp-audit:ro`.
|
||||
3. Audit events are evidence, not replay payloads. The historical journal can
|
||||
link to an object by a public selector but cannot reconstruct omitted fields.
|
||||
4. Treat job lifecycle statuses `accepted`, `running`, `done` and `cancelled`
|
||||
as operational state, not failures. Expected rejections are shown separately
|
||||
from adapter exceptions.
|
||||
5. Retain both `duration_ms` (Observer-facing REST wall time) and optional
|
||||
result timings. Do not manufacture nested timings if the adapter did not
|
||||
return them; the first precise per-span waterfall requires an Observer-owned
|
||||
read-only proxy/session trace.
|
||||
|
||||
## Delivery plan
|
||||
|
||||
### Delivered MVP
|
||||
|
||||
- Rotated REST JSONL reader, safe event details and filters.
|
||||
- p50/p95/max latency by method, error fingerprint grouping and guidance.
|
||||
- Read-only Docker deployment and health endpoint.
|
||||
- MCP-to-REST request correlation by `request_id`; absent REST pair is shown as
|
||||
a transport boundary, not a decoder failure.
|
||||
- A bounded read-only coverage refresh (`help.methods` and
|
||||
`metadata.adapter.audit`) stored in Observer's own state volume, with the
|
||||
latest 50 snapshots per base and a schema/method/count delta.
|
||||
- A best-effort background coverage snapshot every 900 seconds for `upo_test`.
|
||||
It has a 300-second minimum interval and must never affect UI availability.
|
||||
|
||||
### Next safe increments
|
||||
|
||||
1. Add an Observer-owned, read-only allowlist proxy. It can save complete
|
||||
*sanitized* read request payloads for user-initiated replay and form a true
|
||||
parent/child waterfall; no historical write replay.
|
||||
2. Periodically invoke `metadata.adapter.audit` through that proxy and persist
|
||||
versioned coverage snapshots in an Observer-owned SQLite database.
|
||||
3. Add regression screens: compare coverage/schema/method catalog before and
|
||||
after an adapter release, with an explicit `not comparable` state.
|
||||
4. Add retention, export and role controls before exposing the journal outside
|
||||
the internal network.
|
||||
|
||||
### Explicit non-goals until separately approved
|
||||
|
||||
- No replay of historical write, activation, repository or password operations.
|
||||
- No raw request/response capture solely to make replay convenient.
|
||||
- No automatic remediation, SQL optimization, Configurator launch or mutation.
|
||||
- No claim that a slow request is an adapter defect without repeated evidence.
|
||||
|
||||
### Items commonly forgotten in observability work
|
||||
|
||||
- **Clock semantics:** preserve source UTC timestamp and Observer receive time;
|
||||
never compare durations across hosts as if clocks were synchronized.
|
||||
- **Asynchrony:** display job poll calls separately from end-to-end job time;
|
||||
polling volume must not dominate failure charts.
|
||||
- **Cardinality:** bound selector/error fingerprints so arbitrary object names
|
||||
cannot create an unbounded metrics index.
|
||||
- **Release correlation:** record Observer version and adapter health/method
|
||||
catalog snapshot beside each periodic coverage run.
|
||||
- **Availability:** health, disk/read errors and log rotation failures of
|
||||
Observer must appear in its own diagnostics, not as adapter failures.
|
||||
- **Retention and access:** define data lifetime, exported fields and viewer
|
||||
permissions before making port 8031 internet-facing or adding Caddy routes.
|
||||
|
||||
## Review checklist
|
||||
|
||||
- Does a proposed feature work with omitted payload fields rather than guessing?
|
||||
- Does it classify expected rejection separately from exception/transport loss?
|
||||
- Does it preserve `request_id` and public selector provenance?
|
||||
- Is every newly persisted field redacted and bounded by retention?
|
||||
- Can the Observer be stopped without affecting adapter requests?
|
||||
@@ -0,0 +1,50 @@
|
||||
# Прикладной эталон дополнительных реквизитов для разработки
|
||||
|
||||
## Цель
|
||||
|
||||
Опубликовать в изолированной базе `upo_test` read-only endpoint, который
|
||||
читает ПВХ средствами платформы 1С. Это эталон для разработки и регрессионного
|
||||
сравнения SQL-адаптера; адаптер к endpoint не подключается.
|
||||
|
||||
## Изолированное расширение
|
||||
|
||||
1. В Конфигураторе создайте новое расширение `AdapterAdditionalAttributesBridge`.
|
||||
2. Добавьте серверный общий модуль `ДополнительныеРеквизитыReadOnly` и вставьте
|
||||
содержимое `plugins/1c/bridge/additional_attributes_readonly.bsl`.
|
||||
3. Добавьте HTTP-сервис `AdapterRuntimeBridge` с URL `/runtime-bridge`.
|
||||
4. Добавьте URL-шаблон `rpc`, метод `POST`, и обработчик
|
||||
`ДополнительныеРеквизитыHTTP.ОбработатьRPC`.
|
||||
5. Добавьте серверный модуль `ДополнительныеРеквизитыHTTP` и вставьте
|
||||
`plugins/1c/bridge/additional_attributes_http_handler.bsl`.
|
||||
6. Ограничьте публикацию тестовой сетью и отдельным техническим пользователем
|
||||
только с правами чтения ПВХ и справочника `СтруктурныеЕдиницы` вместе с его
|
||||
табличной частью `ДополнительныеРеквизиты`.
|
||||
7. Обновите конфигурацию базы из расширения и опубликуйте HTTP-сервис.
|
||||
|
||||
Не меняйте существующие `Chatbot`, биллинг, телефонию или сервисы обмена.
|
||||
|
||||
## Проверка endpoint
|
||||
|
||||
```json
|
||||
{"method":"additional_attributes.find","payload":{"base_id":"upo_test","query":"Ответственное направление","include_deleted":false,"limit":10}}
|
||||
```
|
||||
|
||||
Ожидается `status=found` и как минимум `ref`, `description`,
|
||||
`identifier_for_formula`, `property_set`, `value_type`.
|
||||
|
||||
Затем:
|
||||
|
||||
```json
|
||||
{"method":"additional_attributes.storage.resolve","payload":{"base_id":"upo_test","property_ref":"<UUID свойства>","owner_ref":"Catalog.СтруктурныеЕдиницы"}}
|
||||
```
|
||||
|
||||
Ожидается `status=confirmed` и источник
|
||||
`Справочник.СтруктурныеЕдиницы.ДополнительныеРеквизиты`.
|
||||
|
||||
## Использование результата
|
||||
|
||||
Сравните ответ endpoint с `data.list`, `data.schema`,
|
||||
`additional_attributes.find` и `additional_attributes.storage.resolve` SQL
|
||||
адаптера. В код адаптера переносятся только подтверждённые общие правила
|
||||
разбора метаданных и SQL-маршрута; адрес endpoint, учётные данные и вызовы
|
||||
платформы в адаптер не добавляются.
|
||||
@@ -10,6 +10,7 @@ RUN pip install --no-cache-dir pyyaml==6.0.2
|
||||
COPY scripts /app/scripts
|
||||
COPY plugins/1c /app/plugins/1c
|
||||
COPY config /app/config
|
||||
COPY core /app/core
|
||||
COPY registry /app/registry
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
@@ -1003,6 +1003,12 @@ ADAPTER_BASE_ID_REQUIRED_PREFIXES = (
|
||||
"storage.",
|
||||
"templates.",
|
||||
)
|
||||
AGENT_FORBIDDEN_TECHNICAL_SELECTOR_FIELDS = {
|
||||
"table", "file_name", "file_names", "module_ref", "module_id", "stream_index",
|
||||
"bsl_offset", "cas_key", "storage_key", "include_storage", "guid", "object_guid",
|
||||
"form_guid", "extension_guid",
|
||||
}
|
||||
AGENT_CONFIGURATION_METHOD_PREFIXES = ("metadata.", "modules.", "code.", "templates.", "extension.")
|
||||
|
||||
|
||||
def adapter_method_requires_base_id(method: str) -> bool:
|
||||
@@ -1018,29 +1024,99 @@ def validate_adapter_call(method: str, params: dict[str, Any] | None) -> None:
|
||||
if adapter_method_requires_base_id(method) and not str(params.get("base_id") or "").strip():
|
||||
raise ValueError(f"adapter method {method} requires params.base_id")
|
||||
|
||||
|
||||
def agent_technical_selector_fields(value: Any) -> list[str]:
|
||||
"""Reject storage coordinates even when a caller nests them in JSON."""
|
||||
found: set[str] = set()
|
||||
if isinstance(value, dict):
|
||||
for key, nested in value.items():
|
||||
if key in AGENT_FORBIDDEN_TECHNICAL_SELECTOR_FIELDS:
|
||||
found.add(key)
|
||||
found.update(agent_technical_selector_fields(nested))
|
||||
elif isinstance(value, list):
|
||||
for nested in value:
|
||||
found.update(agent_technical_selector_fields(nested))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def prepare_agent_adapter_call(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Keep the agent on public metadata selectors rather than SQL routes."""
|
||||
prepared = dict(params)
|
||||
diagnostic_allowed = str(os.environ.get("ONEC_AGENT_ALLOW_DIAGNOSTIC") or "").strip().casefold() in {"1", "true", "yes", "on"}
|
||||
technical = agent_technical_selector_fields(prepared)
|
||||
if technical and not diagnostic_allowed:
|
||||
raise ValueError(
|
||||
"agent adapter calls require public names/selectors; forbidden technical fields: " + ", ".join(technical)
|
||||
)
|
||||
if method.startswith(AGENT_CONFIGURATION_METHOD_PREFIXES):
|
||||
prepared.setdefault("configuration_view", "effective_working")
|
||||
prepared.setdefault("source_state", "working")
|
||||
return prepared
|
||||
|
||||
def call_adapter(method: str, params: dict[str, Any] | None, *, base_url: str | None = None) -> dict[str, Any]:
|
||||
params = params or {}
|
||||
"""Call the adapter through its public MCP boundary, never its SQL REST surface."""
|
||||
params = prepare_agent_adapter_call(method, params or {})
|
||||
validate_adapter_call(method, params)
|
||||
adapter_url = normalize_base_url(base_url or os.environ.get("ONEC_ADAPTER_URL", "http://docker-gpu.cin.su:8011"))
|
||||
headers = {"Content-Type": "application/json"}
|
||||
token = os.environ.get("ONEC_ADAPTER_TOKEN", "").strip()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(
|
||||
f"{adapter_url}/rpc",
|
||||
data=json.dumps({"method": method, "payload": params}, ensure_ascii=False).encode("utf-8"),
|
||||
mcp_url = normalize_base_url(base_url or os.environ.get("ONEC_MCP_URL", "http://docker.cin.su:8021"))
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
||||
initialize = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": f"onec-agent-init-{uuid.uuid4().hex}",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "onec-agent", "version": "1"},
|
||||
},
|
||||
}
|
||||
init_request = Request(
|
||||
f"{mcp_url}/mcp",
|
||||
data=json.dumps(initialize, ensure_ascii=False).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(init_request, timeout=30) as response:
|
||||
init_raw = json.loads(response.read().decode("utf-8"))
|
||||
session_id = response.headers.get("Mcp-Session-Id")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValueError(f"MCP initialize returned HTTP {exc.code}: {body}") from exc
|
||||
if not isinstance(init_raw, dict) or not isinstance(init_raw.get("result"), dict):
|
||||
raise ValueError("MCP initialize response is not JSON-RPC success")
|
||||
call_headers = dict(headers)
|
||||
if session_id:
|
||||
call_headers["Mcp-Session-Id"] = session_id
|
||||
call = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": f"onec-agent-call-{uuid.uuid4().hex}",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "onec_request", "arguments": {"method": method, "payload": params}},
|
||||
}
|
||||
request = Request(
|
||||
f"{mcp_url}/mcp",
|
||||
data=json.dumps(call, ensure_ascii=False).encode("utf-8"),
|
||||
headers=call_headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=120) as response:
|
||||
raw = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValueError(f"adapter returned HTTP {exc.code}: {body}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("adapter response is not JSON")
|
||||
return raw
|
||||
raise ValueError(f"MCP tool call returned HTTP {exc.code}: {body}") from exc
|
||||
result = raw.get("result") if isinstance(raw, dict) and isinstance(raw.get("result"), dict) else None
|
||||
content = result.get("content") if isinstance(result, dict) and isinstance(result.get("content"), list) else []
|
||||
text = content[0].get("text") if content and isinstance(content[0], dict) else None
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("MCP tool response has no JSON text content")
|
||||
try:
|
||||
decoded = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("MCP tool response text is not JSON") from exc
|
||||
if not isinstance(decoded, dict):
|
||||
raise ValueError("MCP tool response payload is not an object")
|
||||
return decoded
|
||||
|
||||
|
||||
class AgentHandler(BaseHTTPRequestHandler):
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Development oracle for additional requisites
|
||||
|
||||
`additional_attributes_readonly.bsl` is server-side BSL intended only for the
|
||||
isolated `upo_test` development configuration. It is an oracle for comparing
|
||||
the platform result with the SQL adapter result; it is never a dependency of
|
||||
the adapter, including in `upo_test`. It does not write application data or
|
||||
configuration metadata. `additional_attributes_http_handler.bsl` is an
|
||||
optional URL-template handler for manual development checks.
|
||||
|
||||
The service must expose two authenticated read-only operations:
|
||||
|
||||
- `additional_attributes.find` → `НайтиДополнительныеРеквизиты`;
|
||||
- `additional_attributes.storage.resolve` → `МаршрутЗначенийДополнительногоРеквизита`.
|
||||
|
||||
The HTTP wrapper must accept/return JSON and restrict calls to the test
|
||||
network. It must pass only `query`, `include_deleted`, a property UUID, and
|
||||
the public owner selector. Do not accept arbitrary BSL or query text.
|
||||
|
||||
Use the result only to create SQL adapter fixtures and verify its semantic
|
||||
mapping. The adapter itself must not call this endpoint, read its URL, or
|
||||
depend on a 1C runtime connection. A missing physical source in `upo_test`
|
||||
must therefore produce explicit SQL diagnostics, never a platform fallback.
|
||||
|
||||
Before publishing:
|
||||
|
||||
1. Add both modules to a test-only extension or HTTP service in Designer and
|
||||
bind `ОбработатьRPC` to `POST /rpc`.
|
||||
2. Restrict the service to read-only calls and test-network access.
|
||||
3. Create or identify a non-deleted test property `Ответственное направление`.
|
||||
4. Verify that the service returns its UUID, formula identifier, property set,
|
||||
and value type; then verify the storage-route response for
|
||||
`Справочник.СтруктурныеЕдиницы`.
|
||||
@@ -0,0 +1,60 @@
|
||||
// Обработчик URL-шаблона HTTP-сервиса, например POST /runtime-bridge/rpc.
|
||||
// Требует общий серверный модуль ДополнительныеРеквизитыReadOnly
|
||||
// (additional_attributes_readonly.bsl) в составе тестового расширения.
|
||||
|
||||
Функция ОбработатьRPC(Запрос) Экспорт
|
||||
Попытка
|
||||
ДанныеЗапроса = ПрочитатьJSONИзСтроки(Запрос.ПолучитьТелоКакСтроку());
|
||||
Метод = ДанныеЗапроса.method;
|
||||
Параметры = ДанныеЗапроса.payload;
|
||||
Если Метод = "additional_attributes.find" Тогда
|
||||
ТекстПоиска = ПолучитьПараметр(Параметры, "query", "");
|
||||
ВключатьУдаленные = ПолучитьПараметр(Параметры, "include_deleted", Ложь);
|
||||
Результат = Новый Структура("status,properties", "found", ДополнительныеРеквизитыReadOnly.НайтиДополнительныеРеквизиты(ТекстПоиска, ВключатьУдаленные));
|
||||
Если Результат.properties.Количество() = 0 Тогда
|
||||
Результат.status = "not_found";
|
||||
КонецЕсли;
|
||||
ИначеЕсли Метод = "additional_attributes.storage.resolve" Тогда
|
||||
СсылкаСвойства = ПланыВидовХарактеристик.ДополнительныеРеквизитыИСведения.ПолучитьСсылку(Новый УникальныйИдентификатор(Параметры.property_ref));
|
||||
Маршрут = ДополнительныеРеквизитыReadOnly.МаршрутЗначенийДополнительногоРеквизита(СсылкаСвойства, Параметры.owner_ref);
|
||||
Результат = Новый Структура("status,property_ref,owner_ref,storage,scd_join", "confirmed", Параметры.property_ref, Параметры.owner_ref,
|
||||
Новый Структура("source_ref,source_kind,fields", Маршрут.source, "TabularSection", Новый Структура("object,property,value", Маршрут.object_field, Маршрут.property_field, Маршрут.value_field)),
|
||||
Новый Структура("source,alias,condition,value_expression,parameters", Маршрут.source, "ДополнительныеРеквизиты", Маршрут.query_join, "ДополнительныеРеквизиты.Значение", Новый Структура("Свойство", Маршрут.parameter)));
|
||||
Иначе
|
||||
Возврат ОтветJSON(405, Новый Структура("status,error", "invalid_argument", "Unsupported read-only bridge method."));
|
||||
КонецЕсли;
|
||||
Возврат ОтветJSON(200, Результат);
|
||||
Исключение
|
||||
// Не передаем внутренний стек и сведения о подключении.
|
||||
Возврат ОтветJSON(400, Новый Структура("status,error", "error", "Invalid read-only bridge request."));
|
||||
КонецПопытки;
|
||||
КонецФункции
|
||||
|
||||
Функция ПолучитьПараметр(СтруктураПараметров, Имя, ЗначениеПоУмолчанию) Экспорт
|
||||
Значение = ЗначениеПоУмолчанию;
|
||||
Если СтруктураПараметров.Свойство(Имя, Значение) Тогда
|
||||
Возврат Значение;
|
||||
КонецЕсли;
|
||||
Возврат ЗначениеПоУмолчанию;
|
||||
КонецФункции
|
||||
|
||||
Функция ПрочитатьJSONИзСтроки(ТекстJSON) Экспорт
|
||||
ЧтениеJSON = Новый ЧтениеJSON;
|
||||
ЧтениеJSON.УстановитьСтроку(ТекстJSON);
|
||||
Попытка
|
||||
Возврат ПрочитатьJSON(ЧтениеJSON);
|
||||
Наконец
|
||||
ЧтениеJSON.Закрыть();
|
||||
КонецПопытки;
|
||||
КонецФункции
|
||||
|
||||
Функция ОтветJSON(КодСостояния, Данные) Экспорт
|
||||
ЗаписьJSON = Новый ЗаписьJSON;
|
||||
ЗаписьJSON.УстановитьСтроку();
|
||||
ЗаписатьJSON(ЗаписьJSON, Данные);
|
||||
ТекстJSON = ЗаписьJSON.Закрыть();
|
||||
Ответ = Новый HTTPСервисОтвет(КодСостояния);
|
||||
Ответ.УстановитьТелоИзСтроки(ТекстJSON, КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
|
||||
Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
|
||||
Возврат Ответ;
|
||||
КонецФункции
|
||||
@@ -0,0 +1,85 @@
|
||||
// Общий модуль серверного HTTP-сервиса. Все экспортные методы только читают данные.
|
||||
// Модуль предназначен для публикации в тестовой конфигурации, а не для выполнения
|
||||
// из SQL-адаптера. Аутентификацию и разбор HTTP-запроса реализует модуль сервиса.
|
||||
|
||||
Функция НайтиДополнительныеРеквизиты(ТекстПоиска = "", ВключатьПомеченныеНаУдаление = Ложь) Экспорт
|
||||
|
||||
Результат = Новый Массив;
|
||||
Выборка = ПланыВидовХарактеристик.ДополнительныеРеквизитыИСведения.Выбрать();
|
||||
Пока Выборка.Следующий() Цикл
|
||||
Если Не ВключатьПомеченныеНаУдаление И Выборка.ПометкаУдаления Тогда
|
||||
Продолжить;
|
||||
КонецЕсли;
|
||||
Если ЗначениеЗаполнено(ТекстПоиска)
|
||||
И СтрНайти(НРег(Выборка.Наименование), НРег(ТекстПоиска)) = 0 Тогда
|
||||
Продолжить;
|
||||
КонецЕсли;
|
||||
|
||||
ОбъектСвойства = Выборка.ПолучитьОбъект();
|
||||
СтрокаСвойства = Новый Структура;
|
||||
СтрокаСвойства.Вставить("ref", Строка(Выборка.Ссылка.УникальныйИдентификатор()));
|
||||
СтрокаСвойства.Вставить("description", Выборка.Наименование);
|
||||
СтрокаСвойства.Вставить("marked_for_deletion", Выборка.ПометкаУдаления);
|
||||
ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "Имя", "name");
|
||||
ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "ИдентификаторДляФормул", "identifier_for_formula");
|
||||
ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "НаборСвойств", "property_set");
|
||||
ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "ТипЗначения", "value_type");
|
||||
// Отдельно фиксируем наличие реквизита в объекте ПВХ. Это позволяет
|
||||
// SQL-разработке отличить пустое значение от отсутствующей семантики.
|
||||
СтрокаСвойства.Вставить("semantic_fields", Новый Структура(
|
||||
"name,identifier_for_formula,property_set,value_type",
|
||||
СтрокаСвойства.Свойство("name"),
|
||||
СтрокаСвойства.Свойство("identifier_for_formula"),
|
||||
СтрокаСвойства.Свойство("property_set"),
|
||||
СтрокаСвойства.Свойство("value_type")));
|
||||
Результат.Добавить(СтрокаСвойства);
|
||||
КонецЦикла;
|
||||
|
||||
Возврат Результат;
|
||||
КонецФункции
|
||||
|
||||
Функция МаршрутЗначенийДополнительногоРеквизита(Свойство, ВладелецМетаданных) Экспорт
|
||||
|
||||
Если ТипЗнч(Свойство) <> Тип("ПланВидовХарактеристикСсылка.ДополнительныеРеквизитыИСведения") Тогда
|
||||
ВызватьИсключение "Свойство должно быть ссылкой ПВХ ДополнительныеРеквизитыИСведения.";
|
||||
КонецЕсли;
|
||||
Если ВладелецМетаданных <> "Справочник.СтруктурныеЕдиницы" Тогда
|
||||
ВызватьИсключение "Маршрут подтвержден только для Справочник.СтруктурныеЕдиницы.";
|
||||
КонецЕсли;
|
||||
|
||||
// Текст предназначен для СКД и не исполняется сервисом. В этой
|
||||
// конфигурации значения подтверждённо находятся в табличной части владельца.
|
||||
Возврат Новый Структура(
|
||||
"source,object_field,property_field,value_field,query_join,parameter",
|
||||
"Справочник.СтруктурныеЕдиницы.ДополнительныеРеквизиты",
|
||||
"Ссылка",
|
||||
"Свойство",
|
||||
"Значение",
|
||||
"ЛЕВОЕ СОЕДИНЕНИЕ Справочник.СтруктурныеЕдиницы.ДополнительныеРеквизиты КАК ДополнительныеРеквизиты "
|
||||
+ "ПО ДополнительныеРеквизиты.Ссылка = СтруктурныеЕдиницы.Ссылка "
|
||||
+ "И ДополнительныеРеквизиты.Свойство = &Свойство",
|
||||
Строка(Свойство.УникальныйИдентификатор())
|
||||
);
|
||||
КонецФункции
|
||||
|
||||
Процедура ДобавитьСвойствоЕслиЕсть(Приемник, Источник, ИмяСвойства, ИмяПоля) Экспорт
|
||||
ЗначениеСвойства = Неопределено;
|
||||
Если Источник.Свойство(ИмяСвойства, ЗначениеСвойства) Тогда
|
||||
Приемник.Вставить(ИмяПоля, ПредставлениеДляJSON(ЗначениеСвойства));
|
||||
КонецЕсли;
|
||||
КонецПроцедуры
|
||||
|
||||
Функция ПредставлениеДляJSON(ЗначениеСвойства) Экспорт
|
||||
Если ЗначениеСвойства = Неопределено Тогда
|
||||
Возврат Неопределено;
|
||||
КонецЕсли;
|
||||
Если ТипЗнч(ЗначениеСвойства) = Тип("Структура") Или ТипЗнч(ЗначениеСвойства) = Тип("Массив") Тогда
|
||||
Возврат ЗначениеСвойства;
|
||||
КонецЕсли;
|
||||
Попытка
|
||||
УникальныйИдентификатор = ЗначениеСвойства.УникальныйИдентификатор();
|
||||
Возврат Новый Структура("ref,presentation", Строка(УникальныйИдентификатор), Строка(ЗначениеСвойства));
|
||||
Исключение
|
||||
КонецПопытки;
|
||||
Возврат Строка(ЗначениеСвойства);
|
||||
КонецФункции
|
||||
@@ -33,8 +33,15 @@ ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite
|
||||
ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite
|
||||
# Legacy JSON job store is read once for migration only.
|
||||
ONEC_ADAPTER_JOB_STORE=/data/adapter-jobs.json
|
||||
# Repository requests, confirmations, sessions, and audit are stored in
|
||||
# ONEC_ADAPTER_STATE_DB. This legacy JSON is imported once and never updated.
|
||||
ONEC_REPOSITORY_STATE_FILE=/data/onec-repository-locks.json
|
||||
ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups
|
||||
ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning
|
||||
# Activation requests/events are stored in ONEC_ADAPTER_STATE_DB.
|
||||
# Legacy JSON is read once for migration only and is never updated afterwards.
|
||||
ONEC_CONFIGURATION_ACTIVATION_STATE_FILE=/data/onec-configuration-activation-requests.json
|
||||
ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS=1800
|
||||
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240
|
||||
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600
|
||||
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180
|
||||
@@ -43,3 +50,7 @@ ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true
|
||||
# Optional POSIX child-process limits; 0 keeps the platform/container limit.
|
||||
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0
|
||||
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0
|
||||
# Stack traces are hidden from REST/MCP clients unless these test/debug flags
|
||||
# are explicitly enabled.
|
||||
ONEC_ADAPTER_DEBUG_DIAGNOSTICS=false
|
||||
ONEC_MCP_DEBUG_DIAGNOSTICS=false
|
||||
|
||||
@@ -3,7 +3,9 @@ FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir pymssql==2.3.2
|
||||
COPY connector/adapter_1c_server.py /app/adapter_1c_server.py
|
||||
COPY connector/analyze_audit.py /app/analyze_audit.py
|
||||
COPY connector/repository_control.py /app/repository_control.py
|
||||
COPY connector/write /app/write
|
||||
COPY connector/admin /app/admin
|
||||
COPY parser /app/parser
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ The connector is read-first and optimized for an operational coding loop where f
|
||||
|
||||
Preferred live architecture:
|
||||
|
||||
- read-only SQL connector for fast diagnostics and data samples;
|
||||
- lightweight 1C agent for metadata, forms, commands, and BSL modules;
|
||||
- SQL-only connector for diagnostics, metadata decoding, and controlled
|
||||
saved-state work in an explicitly authorised test base;
|
||||
- a human-operated Configurator for viewing and applying pending changes;
|
||||
- cached metadata/module snapshots with freshness checks;
|
||||
- change proposals as reviewable artifacts, not direct production writes.
|
||||
|
||||
@@ -17,7 +18,12 @@ The connector is responsible for:
|
||||
- BSL module search/read;
|
||||
- read-only query validation and execution;
|
||||
- metadata/module snapshots;
|
||||
- change proposals without direct apply.
|
||||
- change proposals and, only where a reverse codec is activation-proven,
|
||||
controlled `ConfigSave`/`ConfigCASSave` writes with rollback evidence.
|
||||
|
||||
The adapter never writes `Config`, `ConfigCAS`, or application data directly.
|
||||
It does not automate Configurator and must not invent unknown 1C structures.
|
||||
The protocol evidence base is [docs/1c-sql-protocol](../../../docs/1c-sql-protocol/README.md).
|
||||
|
||||
Contracts:
|
||||
|
||||
@@ -122,6 +128,57 @@ adapter-owned lock session is supplied. Structural add/delete/rename plans are
|
||||
kept blocked for confirmation because parent and reference objects can also be
|
||||
required.
|
||||
|
||||
## Configuration activation debug workflow
|
||||
|
||||
Activation is a separate boundary from saved-state writes and repository
|
||||
coordination. The current workflow is intentionally debug-only:
|
||||
|
||||
1. `configuration.activation.status`;
|
||||
2. `configuration.activation.plan`;
|
||||
3. `configuration.activation.request`;
|
||||
4. forward the returned request id to `configuration.activation.execute` with
|
||||
`mode=debug` and `confirm_activation=true`;
|
||||
5. inspect or cancel the request through
|
||||
`configuration.activation.request.status`,
|
||||
`configuration.activation.request.cancel`, and
|
||||
`configuration.activation.audit`.
|
||||
|
||||
The request is bound to a live-SQL fingerprint and is rejected when pending
|
||||
files change or the request expires. Requests and events are stored in the
|
||||
adapter-local SQLite selected by `ONEC_ADAPTER_STATE_DB`; they contain no
|
||||
payload bytes or credentials. `ONEC_CONFIGURATION_ACTIVATION_STATE_FILE` is a
|
||||
one-time legacy JSON import source only. `configuration.activation.capabilities`
|
||||
reports runner readiness without returning paths, URLs, selectors, users,
|
||||
passwords, or tokens.
|
||||
`configuration.activation.bridge.probe` can then check the local runner or the
|
||||
authenticated HTTP runner endpoint `/configuration/activation/debug`. The
|
||||
probe verifies only Designer-file availability and infobase-selector presence;
|
||||
it never starts a process.
|
||||
Pass `bridge_debug=true` to `configuration.activation.execute` when the runner
|
||||
must also acknowledge the exact request id and live-SQL fingerprint. The runner
|
||||
returns an opaque SHA-256 debug receipt; mismatched or missing receipts block
|
||||
the request, while a valid receipt adds a `bridge_debug_accepted` audit event.
|
||||
After a manual F7, call `configuration.activation.verify` with the same request
|
||||
id. It reports `not_activated`, `changed_since_request`, or
|
||||
`verified_up_to_date` from a fresh SQL comparison. The last status proves
|
||||
saved/active alignment, not the historical fact that Designer performed the
|
||||
activation.
|
||||
|
||||
Real Designer execution remains disabled. `/UpdateDBCfg` is recorded only as
|
||||
the documented future base-configuration operation. Extension activation stays
|
||||
manual until a separately verified platform command and post-activation check
|
||||
are implemented.
|
||||
|
||||
Activation request mutations use SQLite `BEGIN IMMEDIATE` transactions, so
|
||||
concurrent adapter processes cannot overwrite each other's request/event
|
||||
updates. Saved-state backup retention is explicit:
|
||||
`storage.saved_state.backups.prune` defaults to a dry run, is scoped by
|
||||
`base_id`, preserves the newest requested count, and requires
|
||||
`confirm_delete=true` before deleting adapter-local backup files. Backups
|
||||
referenced by `metadata.write.history` are always protected; when write-history
|
||||
availability cannot be verified, affected backup files are protected
|
||||
fail-closed.
|
||||
|
||||
## Docker Run
|
||||
|
||||
Create a local `.env` from `.env.example`, keep real passwords outside git, and
|
||||
@@ -237,6 +294,8 @@ Current live methods:
|
||||
- `metadata.route.resolve`
|
||||
- `metadata.form.decode`
|
||||
- `metadata.object.attributes`
|
||||
- `metadata.relationship.verify`
|
||||
- `metadata.relationship.find`
|
||||
- `metadata.object.full`
|
||||
- `metadata.snapshot`
|
||||
- `codec.decode`
|
||||
@@ -262,8 +321,11 @@ Client, MCP, and agent code must not add conditions for concrete object names;
|
||||
the adapter owns generic selector normalization.
|
||||
|
||||
Saved-state client calls use the same name-first selectors together with
|
||||
`layer=base_saved_state|extension_saved_state`. SQL tables, file names, GUID
|
||||
owners, and module handles are diagnostic continuations exposed only with
|
||||
`layer=base_saved_state|extension_saved_state`. Public module search results
|
||||
include a name-first `write_plan_target` (`ref`, form/module names, and a
|
||||
1-based stream ordinal); `metadata.write.plan` resolves its physical handle
|
||||
internally. SQL tables, file names, GUID owners, module handles, and payload
|
||||
hashes remain diagnostic continuations exposed only with
|
||||
`include_storage=true`. Every public RPC follow-up is shaped as
|
||||
`{"method": "...", "params": {...}}`; `payload` is not the arguments field of
|
||||
`next_call` or `next_resolution`.
|
||||
@@ -284,6 +346,9 @@ Agent-facing code write rule:
|
||||
public path such as `<extension>.<form>.<routine>` plus full code text.
|
||||
- `code.write` automatically targets the saved-state layer and reports
|
||||
`write_mode.target=saved_state` with `activation_state=not_activated`.
|
||||
- Write plans for embedded form-container modules return a ready
|
||||
`code.write` hint; they do not incorrectly request a nonexistent
|
||||
`#stream:<index>`.
|
||||
- Use `code.read`/`code.search` with the default working state for current
|
||||
programming-time code; use `state=both` only when an explicit saved vs active
|
||||
comparison is needed.
|
||||
@@ -293,6 +358,10 @@ without physical SQL/storage traces by default. `metadata.object.decode` also
|
||||
returns a 1C-facing decoded object profile by default; pass
|
||||
`include_storage=true` only when adapter diagnostics need the underlying decoded
|
||||
payload metadata, record containers, or DBNames/storage routes.
|
||||
Exact extension objects use the same public `kind` + `name`/`ref` selectors as
|
||||
base objects. `metadata.object.modules` includes owned form modules and returns
|
||||
qualified names such as
|
||||
`test2.Форма.t_Форма.Модуль формы`; extension GUIDs and CAS keys remain internal.
|
||||
|
||||
`metadata.object.properties` is the unified property endpoint for every 1C
|
||||
metadata kind. It selects a kind-specific SQL decoder for `Configuration`,
|
||||
@@ -345,6 +414,32 @@ inspection for user-facing answers. The object can be selected by `guid`, by
|
||||
`kind` + `name`, or by 1-based `ordinal` within `metadata.objects.list` for that
|
||||
kind.
|
||||
|
||||
For a safe answer to "are these objects linked?", do not infer a link from a
|
||||
similar field name, BSL mention, or a runtime value. Use
|
||||
`metadata.relationship.verify` with an exact source `member` and optional
|
||||
`target_ref`. It returns `confirmed` only when that member's declared 1C type
|
||||
explicitly names the target object; otherwise it returns `not_confirmed` or an
|
||||
explicitly ambiguous result. To discover a direct typed field without knowing
|
||||
its name, call `metadata.relationship.find` with public refs only:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "metadata.relationship.find",
|
||||
"payload": {
|
||||
"base_id": "upo_test",
|
||||
"ref": "Document.СписаниеЗапасов",
|
||||
"target_ref": "Document.РасходнаяНакладная",
|
||||
"direction": "either",
|
||||
"execution_mode": "job"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`direction=either` checks both objects for explicitly declared references and
|
||||
returns the direction of every confirmed edge. A `not_found` result means that
|
||||
no direct declared metadata reference was found; it does not prove that an
|
||||
indirect BSL, query, form, or business-process relationship is absent.
|
||||
|
||||
`metadata.object.full` is the preferred high-level method for agent answers like
|
||||
"show everything about this document". It combines the live object card,
|
||||
semantic sections, decoded forms, BSL module profiles, and counts in one
|
||||
@@ -485,7 +580,33 @@ unless `include_storage=true`.
|
||||
|
||||
`code.search` is the agent-facing wrapper over module search. Its items include
|
||||
`read_selector.method: "code.read"` and preserve `module_ref` when that is the
|
||||
best available safe handle. `code.read` can consume that selector directly.
|
||||
best available safe handle. `code.read` can consume that selector directly;
|
||||
the selector pins the configuration view that produced the hit. A storage
|
||||
stream whose Configurator-tree role is not independently decoded is returned
|
||||
as `bsl_module` with `role_status=unconfirmed` and must not be treated as a
|
||||
command, manager, or a tree path.
|
||||
|
||||
`metadata.object.commands` resolves an `extension` name to the active
|
||||
extension internally before it reads the selected object. A caller provides
|
||||
only the public object and extension selectors; it must not replace them with
|
||||
a base-configuration route or infer a command from a BSL stream suffix. A
|
||||
successful empty command list is the only evidence currently returned for “no
|
||||
decoded commands”; an unresolved object route is reported separately.
|
||||
For object-owned extension forms, `modules.search` and `code.search` resolve
|
||||
the form module from the public owner reference. In the default
|
||||
`state=working` view they inspect the saved counterpart first and fall back to
|
||||
the active module only when needed; `state=active` never returns saved-only
|
||||
text. Saved matches carry `activation_state=saved_state` and
|
||||
`current_state.activation_state=not_activated`.
|
||||
For an active extension form selector, `code.read state=both` resolves the
|
||||
saved form by logical owner/form identity, even when active and saved CAS file
|
||||
names differ, and reports live text SHA1 comparison evidence.
|
||||
|
||||
`metadata.resolve_overrides` uses the same name-first form ownership and
|
||||
saved-first working-state rules. A public selector such as `Catalog.test2`
|
||||
therefore resolves routines located in forms owned by that extension object;
|
||||
the returned chain identifies the form and activation state without exposing
|
||||
the object's physical SQL route.
|
||||
|
||||
`metadata.definition.find` accepts public object references such as
|
||||
`Обработка.<Name>` or `Document.<Name>` in `query` and the common object
|
||||
@@ -550,3 +671,49 @@ be proven, the adapter must re-read live SQL or return an explicit stale-cache
|
||||
error.
|
||||
|
||||
Operational runbook: `docs/runbooks/1c-operational-coding.md`.
|
||||
|
||||
## Development audit telemetry
|
||||
|
||||
Every REST `/rpc` call produces a privacy-safe JSONL event in
|
||||
`/data/adapter-audit.jsonl`. It contains the UTC time, correlation id, public
|
||||
method and selector summary, result status/error, duration, public route and
|
||||
resolver timings/counts (when a write route is involved), and exception type
|
||||
when the request itself fails. A `public_write_route_unresolved` event retains
|
||||
the safe resolver status/error/candidate count so it can be diagnosed without
|
||||
asking a caller for a module handle. It deliberately excludes BSL text, SQL
|
||||
payloads, physical file names, stream indexes, credentials, and SQL connection
|
||||
details. The MCP proxy forwards its generated
|
||||
request id in `X-Request-ID`, so an agent response can be correlated with the
|
||||
REST record. The log is shared by all configured
|
||||
`base_id` values so cross-base failures and slow calls can be compared.
|
||||
|
||||
For development, the default retention is deliberately generous: 50 MiB per
|
||||
file and ten retained files. Configure `ONEC_ADAPTER_AUDIT_MAX_BYTES` and
|
||||
`ONEC_ADAPTER_AUDIT_KEEP_FILES` to change it. Rotation is best-effort and can
|
||||
never fail an adapter request. A caller may supply an `X-Request-ID` header to
|
||||
correlate a client event with the REST record.
|
||||
|
||||
The `adapter-1c-audit` Compose service writes an aggregate report every 15
|
||||
minutes to `/data/adapter-audit-reports/latest.json`; set
|
||||
`ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS` to alter the interval. It reports base
|
||||
distribution, failures, slow operations, malformed rows, and recent failures.
|
||||
For an immediate manual report, run `python scripts/analyze_1c_adapter_audit.py`
|
||||
against a copied log or `python /app/analyze_audit.py` inside the REST image.
|
||||
The MCP proxy has its own persistent `/data/mcp-audit.jsonl` and periodic
|
||||
summary: it records failures that happen before a request reaches REST.
|
||||
|
||||
For an extension-wide `code.search` without a concrete object selector,
|
||||
`timeout_seconds` is a total search budget. If owner-route discovery consumes
|
||||
that budget, the adapter returns `status=partial` with
|
||||
`diagnostics.code=time_budget_exhausted`; it does not continue serial owner
|
||||
probes in the background. Narrow routine work with `ref` or `kind`/`name`.
|
||||
|
||||
REST deployments use a five-minute Docker stop grace period. On `SIGTERM` the
|
||||
adapter stops accepting new work and waits for already-running request threads,
|
||||
including verified saved-state writes, to complete. Do not deploy the REST
|
||||
service while an operator is intentionally running a production-base write;
|
||||
the deployment prevents a half-response, but the client should still retry only
|
||||
after it receives a structured result.
|
||||
The deployment script also waits for `health.runtime.active_rpc_count=0` before
|
||||
recreating REST. `-SkipDrainCheck` is an emergency-only override and must not
|
||||
be used while a write is in progress.
|
||||
|
||||
+16681
-542
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
"""Summarize privacy-safe adapter JSONL telemetry inside the REST image."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--log", default="/data/adapter-audit.jsonl")
|
||||
parser.add_argument("--slow-ms", type=int, default=5_000)
|
||||
parser.add_argument("--limit", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
path = Path(args.log)
|
||||
rows: list[dict] = []
|
||||
malformed_rows = 0
|
||||
if path.exists():
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed_rows += 1
|
||||
continue
|
||||
if item.get("event") == "adapter_rpc":
|
||||
rows.append(item)
|
||||
by_base = Counter(str((row.get("request") or {}).get("base_id") or "<none>") for row in rows)
|
||||
exceptions = [row for row in rows if str(row.get("status") or "") == "exception" or row.get("error") == "request_exception"]
|
||||
rejected = [row for row in rows if str(row.get("status") or "") in {"blocked", "unsupported", "invalid_argument"}]
|
||||
slow = sorted((row for row in rows if int(row.get("duration_ms") or 0) >= args.slow_ms), key=lambda row: int(row.get("duration_ms") or 0), reverse=True)
|
||||
print(json.dumps({
|
||||
"schema": "onec_adapter_audit_summary.v1",
|
||||
"status": "ok" if path.exists() else "log_not_found",
|
||||
"events": len(rows), "malformed_rows": malformed_rows,
|
||||
"time_range": {"from": rows[0].get("time") if rows else None, "to": rows[-1].get("time") if rows else None},
|
||||
"bases": dict(by_base), "adapter_exceptions": len(exceptions),
|
||||
"expected_rejections": len(rejected),
|
||||
"exception_methods": dict(Counter(str(row.get("method") or "<none>") for row in exceptions).most_common(args.limit)),
|
||||
"slow_threshold_ms": args.slow_ms,
|
||||
"slow": [{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} for row in slow[:args.limit]],
|
||||
"recent_exceptions": [{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "exception_type": row.get("exception_type"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} for row in exceptions[-args.limit:]],
|
||||
"findings": [
|
||||
*([{"priority": "P1", "kind": "adapter_exception", "count": len(exceptions), "next_action": "Inspect the matching REST request_id and exception_type; reproduce only on upo_test before changing code."}] if exceptions else []),
|
||||
*([{"priority": "P2", "kind": "slow_calls", "count": len(slow), "next_action": "Inspect timings_ms for the listed methods; optimise only after a repeated pattern is confirmed."}] if slow else []),
|
||||
*([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect log rotation and container shutdown events."}] if malformed_rows else []),
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -54,6 +54,140 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
/configuration/activation/status:
|
||||
post:
|
||||
operationId: getConfigurationActivationStatus
|
||||
summary: Read the live saved-state to active boundary
|
||||
description: Compares saved-state and active configuration layers without cache, vector search, Designer execution, or configuration mutation.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequest"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/plan:
|
||||
post:
|
||||
operationId: planConfigurationActivation
|
||||
summary: Build a read-only activation handoff plan
|
||||
description: Returns review and verification calls plus a manual Designer action when activation is required. It never starts Designer.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequest"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request:
|
||||
post:
|
||||
operationId: createConfigurationActivationRequest
|
||||
summary: Create a fingerprinted activation request
|
||||
description: Persists an expiring adapter-local request bound to the exact live-SQL saved-state fingerprint. It does not start Designer.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestCreate"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request/status:
|
||||
post:
|
||||
operationId: getConfigurationActivationRequest
|
||||
summary: Read activation request state
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestStatus"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request/cancel:
|
||||
post:
|
||||
operationId: cancelConfigurationActivationRequest
|
||||
summary: Cancel one activation request
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestCancel"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/audit:
|
||||
post:
|
||||
operationId: auditConfigurationActivationRequests
|
||||
summary: List activation request lifecycle events for one base
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationAudit"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/capabilities:
|
||||
post:
|
||||
operationId: getConfigurationActivationCapabilities
|
||||
summary: Read safe Designer bridge readiness
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationCapabilities"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/bridge/probe:
|
||||
post:
|
||||
operationId: probeConfigurationActivationBridge
|
||||
summary: Probe local or HTTP Designer runner readiness without execution
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationBridgeProbe"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/execute:
|
||||
post:
|
||||
operationId: debugConfigurationActivation
|
||||
summary: Accept a fingerprinted activation request in debug mode
|
||||
description: Revalidates the exact live-SQL fingerprint and records debug acceptance. Real Designer execution is unavailable.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationExecute"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/verify:
|
||||
post:
|
||||
operationId: verifyConfigurationActivation
|
||||
summary: Verify saved/active alignment for one activation request
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationVerify"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/extensions:
|
||||
get:
|
||||
operationId: listExtensions
|
||||
@@ -258,7 +392,7 @@ paths:
|
||||
type: string
|
||||
enum: [Params, Config, ConfigSave, ConfigCAS, ConfigCASSave]
|
||||
default: Config
|
||||
description: Storage table to read from. Use Config for active config, ConfigSave for saved config.
|
||||
description: Storage table to read from. Exact public extension selectors resolve to ConfigCAS internally; physical routes remain hidden unless include_storage=true.
|
||||
guid:
|
||||
type: string
|
||||
description: Config object GUID. If omitted, kind and name are used.
|
||||
@@ -451,7 +585,7 @@ paths:
|
||||
type: string
|
||||
enum: [Params, Config, ConfigSave, ConfigCAS, ConfigCASSave]
|
||||
default: Config
|
||||
description: Storage table to read from. Use Config for active config, ConfigSave for saved config.
|
||||
description: Storage table to read from. Exact public extension selectors resolve to ConfigCAS internally; physical routes remain hidden unless include_storage=true.
|
||||
guid:
|
||||
type: string
|
||||
description: Config object GUID. If omitted, kind and name are used.
|
||||
@@ -461,7 +595,7 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Live BSL module stream ids for a metadata object.
|
||||
description: Public BSL modules owned by the selected object, including owned form modules. Exact extension objects are resolved from public kind/name or ref.
|
||||
/metadata/object/related:
|
||||
post:
|
||||
operationId: listMetadataObjectRelated
|
||||
@@ -958,6 +1092,11 @@ paths:
|
||||
ref:
|
||||
type: string
|
||||
description: Public object reference such as Справочник.Номенклатура.
|
||||
state:
|
||||
type: string
|
||||
enum: [working, active, save, both]
|
||||
default: working
|
||||
description: Working is saved-first with active fallback, including form modules owned by extension objects resolved from the public selector.
|
||||
responses:
|
||||
"200":
|
||||
description: Read-only routine override/action chain across base and extension modules.
|
||||
@@ -983,6 +1122,9 @@ paths:
|
||||
source:
|
||||
type: string
|
||||
enum: [configuration, extension]
|
||||
activation_state:
|
||||
type: string
|
||||
enum: [active, saved_state]
|
||||
method:
|
||||
type: string
|
||||
line_start:
|
||||
@@ -998,7 +1140,7 @@ paths:
|
||||
enum: [ok, unknown]
|
||||
operation_class:
|
||||
type: string
|
||||
description: base_definition, insert_before, insert_after, replace, replace_with_control, or unknown_extension_action.
|
||||
description: base_definition, extension_definition, insert_before, insert_after, replace, replace_with_control, or unknown_extension_action.
|
||||
requires_control_fragment:
|
||||
type: boolean
|
||||
extension_actions:
|
||||
@@ -1208,6 +1350,20 @@ paths:
|
||||
module_ref:
|
||||
type: string
|
||||
description: Opaque module_ref from code.search/modules.search read_selector when already known.
|
||||
expected_sha1:
|
||||
type: string
|
||||
description: Optional container SHA1 precondition, normally forwarded from metadata.write.preflight write_context.
|
||||
expected_text_sha1:
|
||||
type: string
|
||||
description: Optional BSL text SHA1 precondition, normally forwarded from metadata.write.preflight write_context.
|
||||
repository_lock:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Forwardable repository.lock.confirm write_context.
|
||||
write_context:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Forwardable context returned by metadata.write.preflight.
|
||||
routine_name:
|
||||
type: string
|
||||
routine_text:
|
||||
@@ -1483,6 +1639,26 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: routine_name
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Select one BSL procedure/function. Its text is compact by default.
|
||||
- name: include_routines
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Include the routine catalogue when a routine is selected.
|
||||
- name: include_summary
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Include the module summary when a routine is selected.
|
||||
responses:
|
||||
"200":
|
||||
description: BSL module content.
|
||||
@@ -1725,6 +1901,51 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: Saved-state apply backups list with source metadata and sha1/byte counts. Payload hex is not returned.
|
||||
/storage/saved-state/backups/prune:
|
||||
post:
|
||||
operationId: pruneSavedStateBackups
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [base_id]
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
table:
|
||||
type: string
|
||||
enum: [ConfigSave, ConfigCASSave]
|
||||
file_name:
|
||||
type: string
|
||||
older_than_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 3650
|
||||
default: 30
|
||||
keep_latest:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 10000
|
||||
default: 20
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10000
|
||||
default: 500
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: true
|
||||
confirm_delete:
|
||||
type: boolean
|
||||
default: false
|
||||
diagnostic:
|
||||
type: boolean
|
||||
description: Required when called through the generic MCP diagnostic policy.
|
||||
responses:
|
||||
"200":
|
||||
description: Dry-run selection or confirmed deletion of adapter-local saved-state backup files. Backups referenced by write history are always protected, with fail-closed protection when history cannot be verified.
|
||||
/access/graph:
|
||||
post:
|
||||
operationId: buildAccessGraph
|
||||
@@ -2246,6 +2467,156 @@ components:
|
||||
type: http
|
||||
scheme: bearer
|
||||
schemas:
|
||||
ConfigurationActivationRequest:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
default: 5000
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
include_files:
|
||||
type: boolean
|
||||
default: false
|
||||
include_storage:
|
||||
type: boolean
|
||||
default: false
|
||||
ConfigurationActivationRequestCreate:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
default: 5000
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
ttl_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 86400
|
||||
default: 1800
|
||||
ConfigurationActivationRequestStatus:
|
||||
type: object
|
||||
required: [request_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
ConfigurationActivationRequestCancel:
|
||||
type: object
|
||||
required: [request_id, confirm_cancel]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
confirm_cancel:
|
||||
type: boolean
|
||||
const: true
|
||||
ConfigurationActivationAudit:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
status:
|
||||
type: string
|
||||
ConfigurationActivationCapabilities:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
ConfigurationActivationBridgeProbe:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 60
|
||||
default: 10
|
||||
ConfigurationActivationExecute:
|
||||
type: object
|
||||
required: [base_id, request_id, confirm_activation]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
mode:
|
||||
type: string
|
||||
const: debug
|
||||
default: debug
|
||||
confirm_activation:
|
||||
type: boolean
|
||||
const: true
|
||||
bridge_debug:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Require an end-to-end local/HTTP runner debug receipt without starting Designer.
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
ConfigurationActivationVerify:
|
||||
type: object
|
||||
required: [base_id, request_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
AdapterRpcRequest:
|
||||
type: object
|
||||
required: [method]
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Исследования SQL: активность расширений
|
||||
|
||||
Статус: `in_progress`. Этот журнал отделяет наблюдения от гипотез. Ничего из
|
||||
раздела «гипотеза» не используется адаптером как runtime-правило.
|
||||
|
||||
## 2026-08-02 — baseline `upo_test`
|
||||
|
||||
Цель: установить доказанный SQL-признак флажка «Активно» расширения в
|
||||
Конфигураторе.
|
||||
|
||||
Снимок выполнен только чтением из `dbo._ExtensionsInfo` для имён
|
||||
`фс_Отчеты` и `фс_Отчеты1`. Зафиксированы все известные скалярные поля строки
|
||||
и SHA1 `_ExtensionZippedInfo`; бинарные данные и учётные сведения не сохранены.
|
||||
|
||||
| name | `_IDRRef` (hex) | `_ExtensionOrder` | `_UpdateTime` | `_ExtensionUsePurpose` | `_ExtensionScope` | zipped bytes | zipped SHA1 |
|
||||
| --- | --- | ---: | --- | ---: | ---: | ---: | --- |
|
||||
| `фс_Отчеты` | `0x8287005056B0D48311F13D089B11F844` | 21 | 4026-05-07 23:45:25 | 2 | 1 | 188 | `0D07CB6CE05AADB301A002CAB7312AD6EA64A344` |
|
||||
| `фс_Отчеты1` | *строка отсутствует* | — | — | — | — | — | — |
|
||||
|
||||
### Подтверждено
|
||||
|
||||
- Наличие строки в `_ExtensionsInfo` доказывает регистрацию расширения в
|
||||
данном SQL-снимке, но **не** доказывает флажок «Активно» в Конфигураторе.
|
||||
- Поэтому поле `active` адаптера для такого источника возвращается как
|
||||
`null`; прежнее значение `true` было неподтверждённым и удалено.
|
||||
|
||||
### Гипотеза, требующая проверки
|
||||
|
||||
После переключения флажков в Конфигураторе изменится одна или несколько
|
||||
наблюдаемых SQL-структур: строка `_ExtensionsInfo`, поля строки, DBNames-Ext,
|
||||
ConfigCAS/ConfigCASSave или иной live SQL-маркер.
|
||||
|
||||
### Следующий контролируемый опыт
|
||||
|
||||
1. Пользователь активирует `фс_Отчеты` и выключает `фс_Отчеты1` (либо наоборот)
|
||||
в Конфигураторе и сообщает, когда действие сохранено.
|
||||
2. Адаптер снимает тот же снимок `_ExtensionsInfo` и дополнительно сравнивает
|
||||
только подтверждённые live SQL-маркеры.
|
||||
3. Правило будет добавлено в runtime лишь если различие воспроизводится при
|
||||
обратном переключении и однозначно связано с активной композицией.
|
||||
|
||||
### Запрещённый вывод до опыта
|
||||
|
||||
Нельзя отбрасывать расширение из поиска только по факту его наличия или
|
||||
отсутствия в `_ExtensionsInfo`, по совпадающему GUID объекта либо по догадке
|
||||
из названия/порядка расширения.
|
||||
|
||||
## 2026-08-02 — baseline `upo` (текущий опыт)
|
||||
|
||||
Этот снимок является исходной точкой для переключения, которое пользователь
|
||||
будет выполнять в `upo`. Он не смешивается с наблюдением `upo_test` выше.
|
||||
|
||||
| name | `_IDRRef` (hex) | GUID из `_IDRRef` | `_ExtensionOrder` | `_UpdateTime` | `_ExtensionUsePurpose` | `_ExtensionScope` | zipped bytes | zipped SHA1 |
|
||||
| --- | --- | --- | ---: | --- | ---: | ---: | ---: | --- |
|
||||
| `фс_Отчеты` | `0xA0CF005056B59ABC11F13D592F0651F1` | `2f0651f1-3d59-11f1-a0cf-005056b59abc` | 20 | 2001-01-01 00:00:00 | 2 | 1 | 188 | `2718A3CC564F593799EFCF1E716305358AF804E8` |
|
||||
| `фс_Отчеты1` | `0x8294005056B0D48311F18A348E02ACCD` | `8e02accd-8a34-11f1-8294-005056b0d483` | 22 | 4026-08-01 18:51:55 | 2 | 1 | 188 | `A9A4AF42BBA2084141A122F9D7C39D7E1428CB21` |
|
||||
|
||||
На baseline присутствуют обе строки. Значит наличие в `_ExtensionsInfo` не
|
||||
может быть критерием активности: оно не отличает выключенное `фс_Отчеты` от
|
||||
включенного `фс_Отчеты1` на скриншоте пользователя.
|
||||
|
||||
## 2026-08-02 — подтверждённый декодер активности
|
||||
|
||||
Три независимых переключения флажка в Конфигураторе, сохранённые пользователем
|
||||
в `upo`, дали один и тот же результат. Не весь SHA1, а **третий байт с конца**
|
||||
`_ExtensionZippedInfo` меняется вместе с флажком:
|
||||
|
||||
| расширение | длина контейнера | состояние в UI | третий байт с конца |
|
||||
| --- | ---: | --- | --- |
|
||||
| `фс_Отчеты` | 188 | выключено → включено | `81` → `82` |
|
||||
| `фс_Отчеты1` | 188 | включено → выключено | `82` → `81` |
|
||||
| `ЭкстракторДанных1СВBI` | 215 | включено → выключено | `82` → `81` |
|
||||
|
||||
Другие изменения контейнера не являются маркером: например, байт около начала
|
||||
контейнера и весь SHA1 меняются при сохранении Конфигуратором.
|
||||
|
||||
### Runtime-правило (подтверждено для наблюдаемой версии)
|
||||
|
||||
`SUBSTRING(_ExtensionZippedInfo, DATALENGTH(_ExtensionZippedInfo) - 2, 1)`:
|
||||
|
||||
- `0x82` — расширение активно;
|
||||
- `0x81` — расширение выключено;
|
||||
- любое иное значение — `active: null`, `unresolved`.
|
||||
|
||||
Правило декодирует только активность и не интерпретирует остальные байты
|
||||
контейнера. Перед использованием для фильтрации глобального поиска требуется
|
||||
отдельный regression-тест, что выключенная extension route не попадает в
|
||||
`effective_working` code search/read.
|
||||
|
||||
## 2026-08-02 — расширенная матрица флажков
|
||||
|
||||
В Конфигураторе была показана полная таблица расширений с дополнительными
|
||||
флажками: безопасный режим, защита от опасных действий, использование в
|
||||
распределённой ИБ и «использовать основной режим». Их комбинации различаются
|
||||
между активными расширениями. Повторный read-only снимок `upo` дал:
|
||||
|
||||
- 14 расширений с UI-флажком «Активно» получили завершающий байт `82`;
|
||||
- `ЭкстракторДанных1СВBI` и `фс_Отчеты1` с выключенным «Активно» получили
|
||||
`81`;
|
||||
- среди активных строк есть разные состояния каждого показанного
|
||||
дополнительного флажка, но их завершающий байт всё равно `82`.
|
||||
|
||||
### Уточнённый вывод
|
||||
|
||||
`81` и `82` надо рассматривать как два **наблюдаемых кода состояния
|
||||
активности** в третьем байте с конца, а не как полную структуру битовых
|
||||
флажков расширения. Технически это может быть битовое поле, перечисление или
|
||||
маркер внутри более крупного протокола — формат этого байта пока не доказан.
|
||||
Для runtime достаточно точного соответствия `81`/`82`; никаких выводов о
|
||||
других флажках из него делать нельзя.
|
||||
|
||||
### Принятое правило адаптера
|
||||
|
||||
Рабочая композиция адаптера содержит только строки с `active: true` (`82`).
|
||||
По умолчанию неактивные и нераспознанные расширения:
|
||||
|
||||
- не выдаются методом `extensions.list`;
|
||||
- не участвуют в DBNames-Ext, ConfigCAS, manifest и cache-маршрутах;
|
||||
- не участвуют в глобальном поиске модулей и объектов;
|
||||
- не могут стать целью чтения или записи.
|
||||
|
||||
Явная попытка обратиться к известному выключенному расширению завершается
|
||||
`status: unavailable`, `error: extension_inactive`; адаптер не читает и не
|
||||
строит маршрут к его объектам. Это исключает неоднозначность одинакового GUID
|
||||
объекта в активном и выключенном расширениях.
|
||||
|
||||
Это правило распространяется и на технические селекторы: верхнеуровневый
|
||||
`extension_guid`, а также публичный `ConfigCASSave` file route с префиксом
|
||||
GUID. Публичное чтение `ConfigCAS` разрешено только для ключа, который
|
||||
подтверждён манифестом активного расширения; непринадлежащий активной
|
||||
композиции файл получает `extension_route_not_active`. Внутренние SQL-вызовы
|
||||
адаптера отделены от этого публичного барьера, чтобы он мог доказуемо
|
||||
построить маршрут, но не раскрывает эти строки агенту.
|
||||
|
||||
### Неподтверждённая гипотеза
|
||||
|
||||
Остальные флажки записаны в других позициях `_ExtensionZippedInfo` либо в
|
||||
другой SQL-структуре. Это не используется адаптером.
|
||||
|
||||
### Следующий опыт для декодирования остальных флажков (только по необходимости)
|
||||
|
||||
На одном выбранном расширении оставить «Активно» неизменным и переключить
|
||||
ровно один другой флажок, сохранить, затем снять бинарный diff. Повторить
|
||||
обратное переключение. До двухстороннего воспроизведения позиция и смысл
|
||||
изменившихся байтов остаются гипотезой.
|
||||
|
||||
## Неподтверждённое направление — opaque `module_ref`
|
||||
|
||||
Нельзя отбрасывать любой `ConfigCAS`/`ConfigCASSave` `module_ref` только по
|
||||
имени физического файла: у части подтверждённых активных модулей GUID
|
||||
расширения отсутствует в имени и восстанавливается только из доказанного
|
||||
контекста владельца. Ранняя фильтрация такого `module_ref` была проверена и
|
||||
отменена, так как блокировала активные маршруты. Дальнейшее усиление возможно
|
||||
только после доказанного owner-resolution до чтения модуля; до этого нельзя
|
||||
объявлять opaque module_ref маршрутом выключенного расширения или менять его
|
||||
семантику догадкой.
|
||||
|
||||
### Подтверждённое частное правило для `module_ref`
|
||||
|
||||
Если physical `ConfigCASSave module_ref` содержит стандартный префикс
|
||||
`<extension-guid>__`, GUID слоя доказуем до чтения контейнера. Адаптер
|
||||
проверяет этот GUID по активной композиции и возвращает `extension_inactive`
|
||||
для выключенного расширения. Это правило не распространяется на непрозрачные
|
||||
имена файлов без GUID: для них по-прежнему требуется доказательство владельца.
|
||||
@@ -1,6 +1,6 @@
|
||||
id: 1c-designer-sql-decoding-policy
|
||||
status: active
|
||||
summary: "Controlled changes in a disposable 1C base may be made only through 1C clients; the adapter observes and decodes SQL without writing application data."
|
||||
summary: "The adapter is a SQL codec only: it decodes and encodes strictly by the live-SQL-derived, versioned configuration-storage specification. In an explicitly authorised test base it may write only verified configuration saved-state overlays. It never invents 1C structure, BSL, or integrity atoms, and never writes active configuration or application data."
|
||||
|
||||
scope:
|
||||
default_base_id: upo_test
|
||||
@@ -8,8 +8,10 @@ scope:
|
||||
forbidden_base_class: [production, unclassified]
|
||||
platform_mutation_authority:
|
||||
application_data: 1c_enterprise_client
|
||||
metadata_working_state: 1c_designer
|
||||
adapter_role: sql_observer_and_decoder
|
||||
active_configuration: 1c_designer
|
||||
metadata_saved_state: adapter_sql_only_with_verified_codec
|
||||
adapter_role: specification_bound_sql_decoder_and_controlled_saved_state_writer
|
||||
fundamental_rule: "Unknown, incomplete, or ambiguous structure returns explicit evidence and unsupported/partial/ambiguous; no guessed decoding or encoding is permitted."
|
||||
|
||||
credentials:
|
||||
persistence: forbidden_in_repository
|
||||
@@ -35,7 +37,7 @@ experiment:
|
||||
forbidden_selectors_for_callers: [sql_number, physical_table, internal_guid_only]
|
||||
|
||||
sql_observation:
|
||||
adapter_access: read_only
|
||||
adapter_access: sql_only
|
||||
allowed: [SELECT, metadata_schema_inspection, ConfigSave_read, ConfigCASSave_read, application_table_read]
|
||||
forbidden:
|
||||
- direct_application_data_write
|
||||
@@ -43,7 +45,20 @@ sql_observation:
|
||||
- direct_ConfigCAS_write
|
||||
- sql_identity_or_permission_change
|
||||
- trigger_or_profiler_installation
|
||||
rule: "All experimental mutations happen through 1C; SQL is evidence, not the mutation transport."
|
||||
rule: "For production and unclassified bases SQL is evidence only. In the explicitly authorised disposable base, SQL writes are limited to ConfigSave/ConfigCASSave after a proven lossless codec, exact preconditions, atomic paired-file update, backup, and readback verification."
|
||||
|
||||
saved_state_write:
|
||||
allowed_base_id: upo_test
|
||||
allowed_tables: [ConfigSave, ConfigCASSave]
|
||||
forbidden_tables: [Config, ConfigCAS]
|
||||
required:
|
||||
- "Resolve the target by live public-name evidence; do not require callers to supply a physical selector."
|
||||
- "Read and hash every target byte stream before writing."
|
||||
- "Use an exact, unique edit anchor or a proven offset/path selector; otherwise return an ambiguity error."
|
||||
- "For extension saved-state, update the changed payload and the matching __configinfo file-SHA1 reference atomically."
|
||||
- "Preserve unproven service atoms byte-for-byte; never generate a value by guesswork or randomness."
|
||||
- "Create rollback evidence and verify SQL readback after commit."
|
||||
- "Return Configurator refresh guidance based on whether the object existed in saved-state before the write."
|
||||
|
||||
metadata_layers:
|
||||
designer_save:
|
||||
@@ -66,4 +81,3 @@ promotion_gates:
|
||||
- "The rule is reproduced with a second value or a second object of the same shape."
|
||||
- "A regression fixture and decoder test are added."
|
||||
- "Rollback through 1C restores the SQL evidence or the experiment documents an irreversible schema migration."
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -10,6 +12,7 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -43,6 +46,7 @@ SUPPORTED_SUPPORT_MODES = {"none", "editable", "locked", "rules", "unknown"}
|
||||
_BASE_LOCKS: dict[str, threading.Lock] = {}
|
||||
_BASE_LOCKS_GUARD = threading.Lock()
|
||||
_STATE_LOCK = threading.RLock()
|
||||
REPOSITORY_STATE_SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
def external_1c_enabled() -> bool:
|
||||
@@ -124,6 +128,19 @@ def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, A
|
||||
else:
|
||||
item = None
|
||||
if isinstance(base_item, dict) and isinstance(base_item.get("development_layers"), dict):
|
||||
# A disposable base can explicitly declare that its base layer has
|
||||
# no repository at all. Extensions in such a base are not new
|
||||
# repository layers merely because the adapter has discovered them
|
||||
# after the configuration file was written. Inherit only this
|
||||
# unambiguous no-repository fact; never inherit a manual/automatic
|
||||
# repository policy to an extension.
|
||||
base_layer = base_item["development_layers"].get("base")
|
||||
base_repository = base_layer.get("repository") if isinstance(base_layer, dict) and isinstance(base_layer.get("repository"), dict) else None
|
||||
base_mode = str((base_repository or {}).get("mode") or (base_repository or {}).get("lock_mode") or "").strip().casefold()
|
||||
base_connection = str((base_repository or {}).get("connection_state") or "").strip().casefold()
|
||||
if layer_id != "base" and (base_mode == "none" or base_connection == "not_configured"):
|
||||
item = {"mode": "none", "connection_state": "not_configured", "inherited_from_layer": "base"}
|
||||
else:
|
||||
return None, layer_error
|
||||
# Legacy repository-only configuration remains readable for the base layer.
|
||||
if item is None and layer_id == "base" and isinstance(base_item, dict):
|
||||
@@ -148,7 +165,11 @@ def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, A
|
||||
if configured["mode"] == "unknown":
|
||||
return {"mode": "unknown", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
|
||||
if configured["mode"] == "none":
|
||||
return {"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
|
||||
return {
|
||||
"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"],
|
||||
"layer_id": layer_id, "layer": layer_id,
|
||||
**({"inherited_from_layer": configured["inherited_from_layer"]} if configured.get("inherited_from_layer") else {}),
|
||||
}, None
|
||||
configured["backend"] = str(configured.get("backend") or "direct").strip().casefold()
|
||||
configured["layer_id"] = layer_id
|
||||
configured["layer"] = layer_id
|
||||
@@ -207,6 +228,7 @@ def _public_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"layer": config.get("layer"),
|
||||
"lock_mode": config.get("lock_mode"),
|
||||
"connection_state": config.get("connection_state"),
|
||||
"inherited_from_layer": config.get("inherited_from_layer"),
|
||||
"adapter_access_mode": "sql_only" if not external_1c_enabled() else "sql_and_external_1c",
|
||||
"automatic_repository_operations_available": external_1c_enabled(),
|
||||
"endpoint": config.get("endpoint"),
|
||||
@@ -317,6 +339,119 @@ def _run_designer(config: dict[str, Any], operation: list[str], timeout_seconds:
|
||||
}
|
||||
|
||||
|
||||
def activation_debug_probe(
|
||||
base_id: str,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
layer: str,
|
||||
timeout_seconds: int,
|
||||
request_id: str = "",
|
||||
fingerprint: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Probe runner readiness without starting Designer or reading configured credentials."""
|
||||
|
||||
runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"}
|
||||
runner_kind = str(runner.get("kind") or "local").strip().casefold()
|
||||
if runner_kind == "http":
|
||||
url = str(runner.get("url") or "").rstrip("/") + "/configuration/activation/debug"
|
||||
request_body = {
|
||||
"base_id": base_id,
|
||||
"layer": layer,
|
||||
"mode": "debug",
|
||||
}
|
||||
if request_id and fingerprint:
|
||||
request_body["request_id"] = request_id
|
||||
request_body["fingerprint"] = fingerprint
|
||||
body = json.dumps(
|
||||
request_body,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
token_env = str(runner.get("token_env") or "").strip()
|
||||
token = os.environ.get(token_env, "") if token_env else ""
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(url, data=body, headers=headers, method="POST"),
|
||||
timeout=timeout_seconds,
|
||||
) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
result = json.loads(exc.read().decode("utf-8"))
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
result = {
|
||||
"status": "runner_error",
|
||||
"message": f"Activation debug runner returned HTTP {exc.code}.",
|
||||
}
|
||||
return result if isinstance(result, dict) else {
|
||||
"status": "runner_error",
|
||||
"message": f"Activation debug runner returned HTTP {exc.code}.",
|
||||
}
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
return {"status": "runner_error", "message": str(exc)}
|
||||
return result if isinstance(result, dict) else {
|
||||
"status": "runner_error",
|
||||
"message": "Activation debug runner returned a non-object response.",
|
||||
}
|
||||
infobase = config.get("infobase") if isinstance(config.get("infobase"), dict) else {}
|
||||
selector_configured = (
|
||||
sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1
|
||||
)
|
||||
designer_path = str(config.get("designer_path") or "").strip()
|
||||
try:
|
||||
designer_available = bool(designer_path and Path(designer_path).is_file())
|
||||
except OSError:
|
||||
designer_available = False
|
||||
ready = bool(selector_configured and designer_available)
|
||||
debug_acceptance = None
|
||||
if request_id and fingerprint:
|
||||
receipt_source = json.dumps(
|
||||
{
|
||||
"base_id": base_id,
|
||||
"layer": layer,
|
||||
"request_id": request_id,
|
||||
"fingerprint": fingerprint,
|
||||
"mode": "debug",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
debug_acceptance = {
|
||||
"accepted": ready,
|
||||
"request_id": request_id,
|
||||
"fingerprint": fingerprint,
|
||||
"receipt": hashlib.sha256(receipt_source).hexdigest() if ready else None,
|
||||
}
|
||||
return {
|
||||
"schema": "onec_configuration_activation_runner_probe.v1",
|
||||
"status": "ready" if ready else "not_ready",
|
||||
"base_id": base_id,
|
||||
"layer": layer,
|
||||
"runner": {
|
||||
"kind": "local",
|
||||
"reachable": True,
|
||||
"designer_path_configured": bool(designer_path),
|
||||
"designer_available": designer_available,
|
||||
"infobase_selector_configured": selector_configured,
|
||||
},
|
||||
"operation": {
|
||||
"kind": "/UpdateDBCfg" if layer == "base_saved_state" else None,
|
||||
"execution_supported": False,
|
||||
"extension_manual_only": layer in {"all", "extension_saved_state"},
|
||||
},
|
||||
"debug_acceptance": debug_acceptance,
|
||||
"execution": {
|
||||
"mode": "debug",
|
||||
"performed": False,
|
||||
"designer_started": False,
|
||||
"active_configuration_changed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _execute_repository(
|
||||
base_id: str,
|
||||
config: dict[str, Any],
|
||||
@@ -483,8 +618,7 @@ def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
repository_user = requested_repository_user or configured_repository_user
|
||||
request_id = "rreq-" + uuid.uuid4().hex
|
||||
with _STATE_LOCK:
|
||||
state = _read_state()
|
||||
with _state_transaction() as state:
|
||||
state.setdefault("requests", {})[request_id] = {
|
||||
"base_id": base_id,
|
||||
"layer": layer_id,
|
||||
@@ -499,7 +633,6 @@ def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
|
||||
}
|
||||
_audit(state, "lock_request_created", request_id=request_id, base_id=base_id, objects=plan["lock_objects"])
|
||||
_write_state(state)
|
||||
return {
|
||||
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
|
||||
"base_id": base_id, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id,
|
||||
@@ -521,7 +654,21 @@ def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request = (_read_state().get("requests") or {}).get(request_id)
|
||||
if not isinstance(request, dict):
|
||||
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": "not_found", "request_id": request_id}
|
||||
result = {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
|
||||
result = {
|
||||
"schema": "onec_repository_lock_request_status.v1",
|
||||
"method": METHOD_LOCK_REQUEST_STATUS,
|
||||
"status": request.get("status"),
|
||||
"request_id": request_id,
|
||||
# Surface the manual-confirmation scope at top level. Requiring
|
||||
# callers to inspect an opaque persisted request made a pending lock
|
||||
# look context-free and encouraged unsafe confirmation guesses.
|
||||
"base_id": request.get("base_id"),
|
||||
"layer_id": request.get("layer_id") or request.get("layer"),
|
||||
"objects": list(request.get("objects") or []),
|
||||
"repository_user": request.get("repository_user") or None,
|
||||
"native_lock_state": "unknown",
|
||||
"request": request,
|
||||
}
|
||||
if request.get("status") == "pending_user_lock":
|
||||
result["next_method"] = METHOD_CONFIRM
|
||||
result["next_call"] = manual_confirmation_next_call(str(request.get("base_id") or ""), request_id)
|
||||
@@ -532,8 +679,7 @@ def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request_id = str(payload.get("request_id") or "").strip()
|
||||
if payload.get("confirm_cancel") is not True:
|
||||
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "confirmation_required", "request_id": request_id}
|
||||
with _STATE_LOCK:
|
||||
state = _read_state()
|
||||
with _state_transaction() as state:
|
||||
request = (state.get("requests") or {}).get(request_id)
|
||||
if not isinstance(request, dict):
|
||||
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "not_found", "request_id": request_id}
|
||||
@@ -542,20 +688,284 @@ def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request["status"] = "cancelled"
|
||||
request["cancelled_at"] = time.time()
|
||||
_audit(state, "lock_request_cancelled", request_id=request_id, base_id=request.get("base_id"), objects=request.get("objects"))
|
||||
_write_state(state)
|
||||
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "cancelled", "request_id": request_id}
|
||||
|
||||
|
||||
def _state_path() -> Path:
|
||||
"""Legacy JSON path used only for one-time migration to local SQLite."""
|
||||
|
||||
return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json")
|
||||
|
||||
|
||||
def _read_state() -> dict[str, Any]:
|
||||
def _state_db_path() -> Path:
|
||||
"""Adapter-local state database; never points at a configured 1C database."""
|
||||
|
||||
configured = os.environ.get("ONEC_ADAPTER_STATE_DB") or os.environ.get("ONEC_ADAPTER_CACHE_DB")
|
||||
if configured:
|
||||
return Path(configured)
|
||||
legacy_override = os.environ.get("ONEC_REPOSITORY_STATE_FILE")
|
||||
if legacy_override:
|
||||
return Path(legacy_override).with_suffix(".sqlite")
|
||||
return Path("/data/adapter-cache.sqlite")
|
||||
|
||||
|
||||
def _empty_state() -> dict[str, Any]:
|
||||
return {"sessions": {}, "requests": {}, "audit": []}
|
||||
|
||||
|
||||
def _read_legacy_state() -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(_state_path().read_text(encoding="utf-8-sig"))
|
||||
state = value if isinstance(value, dict) else {"sessions": {}, "requests": {}, "audit": []}
|
||||
return value if isinstance(value, dict) else _empty_state()
|
||||
except (OSError, json.JSONDecodeError):
|
||||
state = {"sessions": {}, "requests": {}, "audit": []}
|
||||
return _empty_state()
|
||||
|
||||
|
||||
def _state_connection() -> sqlite3.Connection:
|
||||
path = _state_db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS adapter_state_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS repository_lock_requests (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
base_id TEXT NOT NULL,
|
||||
layer_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS repository_lock_sessions (
|
||||
lock_session_id TEXT PRIMARY KEY,
|
||||
base_id TEXT NOT NULL,
|
||||
layer_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS repository_lock_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
event TEXT NOT NULL,
|
||||
occurred_at REAL NOT NULL,
|
||||
base_id TEXT,
|
||||
request_id TEXT,
|
||||
lock_session_id TEXT,
|
||||
details_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_repository_lock_requests_base_status "
|
||||
"ON repository_lock_requests(base_id, status, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_repository_lock_sessions_base_status "
|
||||
"ON repository_lock_sessions(base_id, status, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_repository_lock_events_base_time "
|
||||
"ON repository_lock_events(base_id, occurred_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO adapter_state_meta(key, value, updated_at)
|
||||
VALUES('adapter_state_schema_version', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value=CASE
|
||||
WHEN CAST(adapter_state_meta.value AS INTEGER) < CAST(excluded.value AS INTEGER)
|
||||
THEN excluded.value
|
||||
ELSE adapter_state_meta.value
|
||||
END,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(str(REPOSITORY_STATE_SCHEMA_VERSION), time.time()),
|
||||
)
|
||||
migration = conn.execute(
|
||||
"SELECT value FROM adapter_state_meta WHERE key='legacy_repository_state_imported'"
|
||||
).fetchone()
|
||||
if not migration:
|
||||
legacy = _read_legacy_state()
|
||||
_sync_state_to_connection(conn, legacy)
|
||||
conn.execute(
|
||||
"INSERT INTO adapter_state_meta(key, value, updated_at) VALUES(?, ?, ?)",
|
||||
("legacy_repository_state_imported", "1", time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _event_id(row: dict[str, Any], index: int) -> str:
|
||||
explicit = str(row.get("event_id") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
source = json.dumps(
|
||||
{
|
||||
"index": index,
|
||||
"event": row.get("event"),
|
||||
"time": row.get("time"),
|
||||
"request_id": row.get("request_id"),
|
||||
"lock_session_id": row.get("lock_session_id"),
|
||||
"base_id": row.get("base_id"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return "legacy-" + hashlib.sha1(source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _load_state_from_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
state = _empty_state()
|
||||
for row in conn.execute("SELECT request_id, payload_json FROM repository_lock_requests"):
|
||||
try:
|
||||
payload = json.loads(row["payload_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
state["requests"][str(row["request_id"])] = payload
|
||||
for row in conn.execute("SELECT lock_session_id, payload_json FROM repository_lock_sessions"):
|
||||
try:
|
||||
payload = json.loads(row["payload_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
state["sessions"][str(row["lock_session_id"])] = payload
|
||||
for row in conn.execute(
|
||||
"SELECT event_id, event, occurred_at, details_json FROM repository_lock_events "
|
||||
"ORDER BY occurred_at, event_id"
|
||||
):
|
||||
try:
|
||||
payload = json.loads(row["details_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
payload.setdefault("event_id", str(row["event_id"]))
|
||||
payload.setdefault("event", str(row["event"]))
|
||||
payload.setdefault("time", float(row["occurred_at"]))
|
||||
state["audit"].append(payload)
|
||||
return state
|
||||
|
||||
|
||||
def _sync_state_to_connection(conn: sqlite3.Connection, value: dict[str, Any]) -> None:
|
||||
now = time.time()
|
||||
for request_id, raw in (value.get("requests") or {}).items():
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
request = dict(raw)
|
||||
created_at = float(request.get("created_at") or now)
|
||||
updated_at = float(
|
||||
request.get("cancelled_at")
|
||||
or request.get("confirmed_at")
|
||||
or request.get("closed_at")
|
||||
or request.get("expired_at")
|
||||
or created_at
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO repository_lock_requests(
|
||||
request_id, base_id, layer_id, status, created_at, updated_at, payload_json
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(request_id) DO UPDATE SET
|
||||
base_id=excluded.base_id,
|
||||
layer_id=excluded.layer_id,
|
||||
status=excluded.status,
|
||||
updated_at=excluded.updated_at,
|
||||
payload_json=excluded.payload_json
|
||||
""",
|
||||
(
|
||||
str(request_id),
|
||||
str(request.get("base_id") or ""),
|
||||
str(request.get("layer_id") or request.get("layer") or "base"),
|
||||
str(request.get("status") or "unknown"),
|
||||
created_at,
|
||||
updated_at,
|
||||
json.dumps(request, ensure_ascii=False, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
for session_id, raw in (value.get("sessions") or {}).items():
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
session = dict(raw)
|
||||
created_at = float(session.get("created_at") or now)
|
||||
updated_at = float(
|
||||
session.get("committed_at")
|
||||
or session.get("released_at")
|
||||
or session.get("closed_at")
|
||||
or session.get("expired_at")
|
||||
or created_at
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO repository_lock_sessions(
|
||||
lock_session_id, base_id, layer_id, status, created_at, updated_at, payload_json
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(lock_session_id) DO UPDATE SET
|
||||
base_id=excluded.base_id,
|
||||
layer_id=excluded.layer_id,
|
||||
status=excluded.status,
|
||||
updated_at=excluded.updated_at,
|
||||
payload_json=excluded.payload_json
|
||||
""",
|
||||
(
|
||||
str(session_id),
|
||||
str(session.get("base_id") or ""),
|
||||
str(session.get("layer_id") or session.get("layer") or "base"),
|
||||
str(session.get("status") or "unknown"),
|
||||
created_at,
|
||||
updated_at,
|
||||
json.dumps(session, ensure_ascii=False, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
audit = [row for row in (value.get("audit") or []) if isinstance(row, dict)][-5000:]
|
||||
for index, raw in enumerate(audit):
|
||||
event = dict(raw)
|
||||
event_id = _event_id(event, index)
|
||||
event["event_id"] = event_id
|
||||
occurred_at = float(event.get("time") or now)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO repository_lock_events(
|
||||
event_id, event, occurred_at, base_id, request_id, lock_session_id, details_json
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
event_id,
|
||||
str(event.get("event") or "unknown"),
|
||||
occurred_at,
|
||||
str(event.get("base_id") or "") or None,
|
||||
str(event.get("request_id") or "") or None,
|
||||
str(event.get("lock_session_id") or "") or None,
|
||||
json.dumps(event, ensure_ascii=False, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _expire_state(state: dict[str, Any]) -> bool:
|
||||
changed = False
|
||||
now = time.time()
|
||||
request_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_REQUEST_TTL_SECONDS") or 86400))
|
||||
session_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS") or 7200))
|
||||
@@ -563,24 +973,57 @@ def _read_state() -> dict[str, Any]:
|
||||
if isinstance(request, dict) and request.get("status") == "pending_user_lock" and now - float(request.get("created_at") if request.get("created_at") is not None else now) > request_ttl:
|
||||
request["status"] = "expired"
|
||||
request["expired_at"] = now
|
||||
changed = True
|
||||
for session in (state.get("sessions") or {}).values():
|
||||
if isinstance(session, dict) and session.get("status") == "manual_confirmed" and now - float(session.get("created_at") if session.get("created_at") is not None else now) > session_ttl:
|
||||
session["status"] = "expired"
|
||||
session["expired_at"] = now
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _read_state() -> dict[str, Any]:
|
||||
with _STATE_LOCK:
|
||||
with _state_connection() as conn:
|
||||
state = _load_state_from_connection(conn)
|
||||
if _expire_state(state):
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
_sync_state_to_connection(conn, state)
|
||||
conn.commit()
|
||||
return state
|
||||
|
||||
|
||||
def _write_state(value: dict[str, Any]) -> None:
|
||||
path = _state_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + f".{uuid.uuid4().hex}.tmp")
|
||||
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
with _STATE_LOCK:
|
||||
with _state_connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
_sync_state_to_connection(conn, value)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _state_transaction() -> Any:
|
||||
"""Serialize a repository state mutation across adapter processes."""
|
||||
|
||||
with _STATE_LOCK:
|
||||
conn = _state_connection()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
state = _load_state_from_connection(conn)
|
||||
_expire_state(state)
|
||||
yield state
|
||||
_sync_state_to_connection(conn, state)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _audit(state: dict[str, Any], event: str, **details: Any) -> None:
|
||||
rows = state.setdefault("audit", [])
|
||||
rows.append({"event": event, "time": time.time(), **details})
|
||||
rows.append({"event_id": "revt-" + uuid.uuid4().hex, "event": event, "time": time.time(), **details})
|
||||
if len(rows) > 5000:
|
||||
del rows[:-5000]
|
||||
|
||||
@@ -659,10 +1102,9 @@ def lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if executed.get("status") != "ok":
|
||||
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "repository_lock_failed", "plan": plan, "execution": executed}
|
||||
session_id = "rlock-" + uuid.uuid4().hex
|
||||
state = _read_state()
|
||||
with _state_transaction() as state:
|
||||
sessions = state.setdefault("sessions", {})
|
||||
sessions[session_id] = {"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
|
||||
_write_state(state)
|
||||
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "acquired", "lock_session_id": session_id, "acquired": plan["lock_objects"], "backend": config.get("backend"), "execution": executed}
|
||||
|
||||
|
||||
@@ -724,19 +1166,33 @@ def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if expected_repository_user and confirmed_repository_user.casefold() != expected_repository_user.casefold():
|
||||
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "repository_user_mismatch", "expected_repository_user": expected_repository_user}
|
||||
session_id = "rlock-" + uuid.uuid4().hex
|
||||
state.setdefault("sessions", {})[session_id] = {
|
||||
with _state_transaction() as current_state:
|
||||
current_request = (current_state.get("requests") or {}).get(request_id) if request_id else None
|
||||
if request_id and (
|
||||
not isinstance(current_request, dict)
|
||||
or current_request.get("base_id") != base_id
|
||||
or current_request.get("status") != "pending_user_lock"
|
||||
):
|
||||
return {
|
||||
"schema": "onec_repository_manual_lock.v1",
|
||||
"method": METHOD_CONFIRM,
|
||||
"base_id": base_id,
|
||||
"status": "blocked",
|
||||
"error": "lock_request_not_pending",
|
||||
"request_id": request_id,
|
||||
}
|
||||
current_state.setdefault("sessions", {})[session_id] = {
|
||||
"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"),
|
||||
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
|
||||
"verification": "user_confirmation_only", "automatically_verified": False,
|
||||
"repository_user": confirmed_repository_user,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
}
|
||||
if isinstance(request, dict):
|
||||
request["status"] = "confirmed_by_user"
|
||||
request["confirmed_at"] = time.time()
|
||||
request["lock_session_id"] = session_id
|
||||
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
|
||||
_write_state(state)
|
||||
if isinstance(current_request, dict):
|
||||
current_request["status"] = "confirmed_by_user"
|
||||
current_request["confirmed_at"] = time.time()
|
||||
current_request["lock_session_id"] = session_id
|
||||
_audit(current_state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
|
||||
return {
|
||||
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
|
||||
"status": "manual_confirmed", "layer_id": layer_id, "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
|
||||
@@ -769,8 +1225,7 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
session_id = str(payload.get("lock_session_id") or "").strip()
|
||||
if payload.get("user_confirmed_released") is not True:
|
||||
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "confirmation_required", "lock_session_id": session_id}
|
||||
with _STATE_LOCK:
|
||||
state = _read_state()
|
||||
with _state_transaction() as state:
|
||||
session = (state.get("sessions") or {}).get(session_id)
|
||||
if not isinstance(session, dict):
|
||||
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "not_found", "lock_session_id": session_id}
|
||||
@@ -797,7 +1252,6 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request["closed_at"] = closed_at
|
||||
if not already_closed:
|
||||
_audit(state, "manual_lock_closed", request_id=request_id or None, lock_session_id=session_id, base_id=session.get("base_id"), objects=session.get("objects"))
|
||||
_write_state(state)
|
||||
return {
|
||||
"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "closed",
|
||||
"lock_session_id": session_id, "request_id": request_id or None,
|
||||
@@ -882,6 +1336,18 @@ def support_gate(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
|
||||
layer = layers.get(layer_id)
|
||||
if not isinstance(layer, dict):
|
||||
# See repository_config(): an explicit repository-less base is the
|
||||
# disposable-test profile. It applies to newly discovered extensions
|
||||
# as well, so a missing per-extension policy cannot turn a permitted
|
||||
# test write into a false "unknown support" block.
|
||||
repository, repository_error = repository_config(base_id, layer_id)
|
||||
if repository_error is None and isinstance(repository, dict) and repository.get("mode") == "none":
|
||||
return {
|
||||
"required": False, "allowed": True,
|
||||
"status": "not_on_support_inherited_no_repository",
|
||||
"layer_id": layer_id,
|
||||
"inherited_from_layer": repository.get("inherited_from_layer"),
|
||||
}
|
||||
return {"required": True, "allowed": False, "status": "blocked_support_layer_unknown", "layer_id": layer_id}
|
||||
support = layer.get("support")
|
||||
if not isinstance(support, dict):
|
||||
@@ -952,11 +1418,16 @@ def commit(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
executed = _execute_repository(base_id, config, "commit", int(payload.get("timeout_seconds") or 180), objects=[str(item) for item in session.get("objects") or []], comment=str(plan["comment"]), keep_locked=payload.get("keep_locked") is True)
|
||||
if executed.get("status") != "ok":
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "repository_commit_failed", "lock_session_id": session_id, "execution": executed}
|
||||
session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
|
||||
session["committed_at"] = time.time()
|
||||
session["commit_comment"] = str(plan["comment"])
|
||||
_write_state(state)
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": session["status"], "lock_session_id": session_id, "committed": session.get("objects"), "keep_locked": payload.get("keep_locked") is True, "execution": executed}
|
||||
with _state_transaction() as current_state:
|
||||
current_session = (current_state.get("sessions") or {}).get(session_id)
|
||||
if not isinstance(current_session, dict) or current_session.get("status") != "acquired":
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id}
|
||||
current_session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
|
||||
current_session["committed_at"] = time.time()
|
||||
current_session["commit_comment"] = str(plan["comment"])
|
||||
final_status = str(current_session["status"])
|
||||
committed_objects = current_session.get("objects")
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": final_status, "lock_session_id": session_id, "committed": committed_objects, "keep_locked": payload.get("keep_locked") is True, "execution": executed}
|
||||
|
||||
|
||||
def unlock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -977,10 +1448,14 @@ def unlock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
executed = _execute_repository(base_id, config, "unlock", int(payload.get("timeout_seconds") or 120), objects=[str(item) for item in session.get("objects") or []])
|
||||
if executed.get("status") != "ok":
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "repository_unlock_failed", "lock_session_id": session_id, "execution": executed}
|
||||
session["status"] = "released"
|
||||
session["released_at"] = time.time()
|
||||
_write_state(state)
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": session.get("objects"), "execution": executed}
|
||||
with _state_transaction() as current_state:
|
||||
current_session = (current_state.get("sessions") or {}).get(session_id)
|
||||
if not isinstance(current_session, dict) or current_session.get("status") != "acquired":
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id}
|
||||
current_session["status"] = "released"
|
||||
current_session["released_at"] = time.time()
|
||||
released_objects = current_session.get("objects")
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": released_objects, "execution": executed}
|
||||
|
||||
|
||||
def call(method: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Typed write-dispatch contracts for the SQL-only 1C adapter."""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Explicit adapter services available to typed write handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterWriteContext:
|
||||
"""Migration boundary: handlers receive services, never server globals.
|
||||
|
||||
`legacy_scheduled_job_writer` is temporary while the existing proven
|
||||
implementation is characterized. It prevents the dispatcher from keeping
|
||||
a direct dependency on that writer and is replaced by granular services
|
||||
when the implementation body moves into the handler.
|
||||
"""
|
||||
|
||||
legacy_scheduled_job_writer: Callable[[dict[str, Any]], dict[str, Any]]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Stable contracts shared by the universal dispatcher and typed handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriteHandler:
|
||||
"""A supported public write surface, not an SQL implementation detail."""
|
||||
|
||||
key: str
|
||||
public_target_kind: str
|
||||
operation: str | None = None
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Typed write-handler declarations.
|
||||
|
||||
Implementations are migrated here one at a time after their existing adapter
|
||||
tests become handler-level characterization tests.
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
# Element, command/button, and embedded-module routing needs decoded form
|
||||
# evidence, so it remains a sub-dispatch inside this public form surface.
|
||||
HANDLER = WriteHandler("form", "form")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("module", "module")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("object_member", "object", "add_attribute")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("object_property", "object")
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from write.context import AdapterWriteContext
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("scheduled_job_schedule", "schedule")
|
||||
|
||||
|
||||
def execute(payload: dict[str, Any], context: AdapterWriteContext) -> dict[str, Any]:
|
||||
"""Run the current proven scheduled-job writer through the handler seam."""
|
||||
return context.legacy_scheduled_job_writer(payload)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Pure, storage-free selection of a typed configuration write handler.
|
||||
|
||||
The registry deliberately contains no SQL, payload, or 1C metadata decoding.
|
||||
It is the first migration seam out of the monolithic adapter server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from write.contracts import WriteHandler
|
||||
from write.handlers.form import HANDLER as FORM_HANDLER
|
||||
from write.handlers.module import HANDLER as MODULE_HANDLER
|
||||
from write.handlers.object_member import HANDLER as OBJECT_MEMBER_HANDLER
|
||||
from write.handlers.object_property import HANDLER as OBJECT_PROPERTY_HANDLER
|
||||
from write.handlers.scheduled_job import HANDLER as SCHEDULE_HANDLER
|
||||
|
||||
|
||||
def select_write_handler(*, target_kind: str, operation: str = "", is_schedule: bool = False) -> WriteHandler | None:
|
||||
"""Return one supported typed handler or ``None`` for a forbidden target.
|
||||
|
||||
Detailed form sub-routing (element, command, embedded module) remains in
|
||||
the form handler. It needs decoded target evidence that is unavailable at
|
||||
this pure public-intent stage.
|
||||
"""
|
||||
if is_schedule:
|
||||
return SCHEDULE_HANDLER
|
||||
normalized_kind = str(target_kind or "").strip().casefold()
|
||||
normalized_operation = str(operation or "").strip().casefold()
|
||||
if normalized_kind in {"object", "объект", "metadata", "метаданные"}:
|
||||
if normalized_operation in {"add_attribute", "attribute_add", "добавить_реквизит", "добавитьреквизит"}:
|
||||
return OBJECT_MEMBER_HANDLER
|
||||
return OBJECT_PROPERTY_HANDLER
|
||||
if normalized_kind in {"module", "модуль", "bsl"}:
|
||||
return MODULE_HANDLER
|
||||
if normalized_kind in {"form", "форма"}:
|
||||
return FORM_HANDLER
|
||||
return None
|
||||
|
||||
|
||||
def registered_handlers() -> list[dict[str, str | None]]:
|
||||
"""Public-safe registry summary; contains no SQL implementation details."""
|
||||
handlers = [MODULE_HANDLER, FORM_HANDLER, OBJECT_PROPERTY_HANDLER, OBJECT_MEMBER_HANDLER, SCHEDULE_HANDLER]
|
||||
return [
|
||||
{"key": handler.key, "target_kind": handler.public_target_kind, "operation": handler.operation}
|
||||
for handler in handlers
|
||||
]
|
||||
@@ -6,6 +6,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
COPY adapter_1c_mcp.py /app/adapter_1c_mcp.py
|
||||
COPY analyze_audit.py /app/analyze_audit.py
|
||||
|
||||
EXPOSE 8021
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import datetime
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
@@ -24,7 +26,7 @@ ROOT_DIR = THIS_FILE.parents[3] if len(THIS_FILE.parents) > 3 else THIS_FILE.par
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
DEFAULT_ADAPTER_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_ADAPTER_URL = "http://docker.cin.su:8011"
|
||||
DEFAULT_ACCESS_REPORT_ROOT = ROOT_DIR / "reports" / "1c-access"
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
MCP_CONTRACT_VERSION = "onec-selector-contract.v1"
|
||||
@@ -32,8 +34,12 @@ MCP_CONTRACT_VERSION = "onec-selector-contract.v1"
|
||||
SESSIONS: dict[str, "queue.Queue[dict[str, Any] | None]"] = {}
|
||||
SESSION_LOCK = threading.Lock()
|
||||
JOB_LOCK = threading.Lock()
|
||||
MCP_AUDIT_LOCK = threading.Lock()
|
||||
SELECTOR_TOKEN_LOCK = threading.Lock()
|
||||
JOBS: dict[str, dict[str, Any]] = {}
|
||||
NEW_METHOD_CACHE: dict[str, dict[str, Any]] = {}
|
||||
SELECTOR_TOKENS: dict[str, dict[str, Any]] = {}
|
||||
SELECTOR_TOKEN_TTL_SECONDS = 600
|
||||
LONG_METHODS = {
|
||||
"metadata.object.attributes",
|
||||
"metadata.object.full",
|
||||
@@ -122,6 +128,10 @@ REST_STATE_BY_SOURCE_STATE = {
|
||||
"all": "both",
|
||||
}
|
||||
REST_STATE_METHODS = {
|
||||
"metadata.object.forms",
|
||||
"metadata.object.form.details",
|
||||
"metadata.form.decode",
|
||||
"metadata.object.full",
|
||||
"metadata.resolve_overrides",
|
||||
"modules.search",
|
||||
"code.search",
|
||||
@@ -150,6 +160,11 @@ GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]
|
||||
FULL_METHOD_SECTIONS = {"card", "semantic", "forms", "templates", "commands", "modules", "parts_summary"}
|
||||
FULL_METHOD_SECTION_ORDER = ["card", "semantic", "modules", "templates", "forms", "commands"]
|
||||
FULL_METHOD_ALL_KEY = "all"
|
||||
TECHNICAL_AGENT_FIELDS = {
|
||||
"table", "file_name", "file_names", "module_ref", "module_id",
|
||||
"stream_index", "bsl_offset", "cas_key", "storage_key",
|
||||
"include_storage", "guid", "object_guid", "form_guid", "extension_guid",
|
||||
}
|
||||
|
||||
|
||||
TOOLS = [
|
||||
@@ -186,17 +201,19 @@ TOOLS = [
|
||||
"description": (
|
||||
"Generic 1C adapter request. For live metadata/modules/code/templates/extensions/query methods, "
|
||||
"payload.base_id is required; get it from user/project context or check a concrete base with onec_health first. "
|
||||
"If you already have module_ref/read_selector, prefer direct read methods before global search. "
|
||||
"Search results include read_selector.method; reuse that selector directly for the next read call. "
|
||||
"Use complete public 1C names first: extension + object ref + child name where applicable. "
|
||||
"Search results declare read_selector.method and include read_selector.selector_token; reuse that token with its declared method for the next read call. "
|
||||
"For a name search across metadata objects, forms, attributes, commands, templates, routines, and extension definitions, use metadata.definition.find; "
|
||||
"use code.search only when the query is BSL text. Scope extension objects with extension.objects.find or metadata.definition.find areas=extensions, never by SQL table names. "
|
||||
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the object selector "
|
||||
"(ref, kind/name/guid, or object_type/object_name/object_guid) or owner_scan_limit. "
|
||||
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the public object selector "
|
||||
"(ref, kind/name, or object_type/object_name). Global code/vector searches resolve "
|
||||
"base module owners lazily from current metadata; use metadata.module_owner_cache.backfill for bounded "
|
||||
"background warming instead of increasing owner_scan_limit on interactive searches. "
|
||||
"The default agent view is configuration_view=effective_working with source_state=working: the logical Designer snapshot, with working changes and extension layers preferred; "
|
||||
"it becomes executable after configuration update, not necessarily now. Use configuration_view=runtime_applied for code executable now, or compare to inspect both. "
|
||||
"Do not select Config/ConfigSave tables in ordinary programming calls. "
|
||||
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name; "
|
||||
"use table/file_name/module_ref only when continuing an explicit include_storage diagnostic result. "
|
||||
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name. "
|
||||
"Do not send GUIDs, table/file_name/module_ref, stream indexes, CAS keys, or include_storage in ordinary agent requests. "
|
||||
"Before writes, call metadata.write.preflight when you need a read-only route/freshness check; it reports "
|
||||
"ready, needs_prepare, needs_resolution, or blocked and never applies SQL writes. "
|
||||
"Repository manual-capture protocol: when repository.lock.request or repository.lock.request.status returns "
|
||||
@@ -310,8 +327,7 @@ TOOLS = [
|
||||
{
|
||||
"method": "code.read",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"module_ref": "<module_ref-from-code-search-read-selector>",
|
||||
"selector_token": "<selector-token-from-code-search>",
|
||||
"include_line_numbers": True,
|
||||
"max_chars": 20000,
|
||||
},
|
||||
@@ -355,8 +371,7 @@ TOOLS = [
|
||||
{
|
||||
"method": "modules.read",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"module_ref": "<module_ref-from-prior-result>",
|
||||
"selector_token": "<selector-token-from-modules-search>",
|
||||
"include_line_numbers": True,
|
||||
"include_text": True,
|
||||
},
|
||||
@@ -510,11 +525,72 @@ TOOLS = [
|
||||
"context_limit": 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.status",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "all",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.plan",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "extension_saved_state",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.request",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "extension_saved_state",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.execute",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"request_id": "<request-id-from-configuration.activation.request>",
|
||||
"mode": "debug",
|
||||
"confirm_activation": True,
|
||||
"bridge_debug": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.request.cancel",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"request_id": "<activation-request-id>",
|
||||
"confirm_cancel": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.capabilities",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "all",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.bridge.probe",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "extension_saved_state",
|
||||
"timeout_seconds": 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.verify",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"request_id": "<activation-request-id>",
|
||||
},
|
||||
},
|
||||
],
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": "Adapter method, for example metadata.objects.list, metadata.object.full, metadata.definition.find, metadata.route.resolve, extension.objects.find, templates.read, templates.analyze, templates.map, metadata.saved_state.prepare, metadata.saved_state.status, metadata.saved_state.diff, metadata.saved_state.changes.list, metadata.saved_state.forms.search, metadata.saved_state.modules.search, metadata.form.write_target.resolve, metadata.form.write_target.verify, metadata.module.write_apply, metadata.write.plan, metadata.write.preflight, metadata.write.capabilities, metadata.write, metadata.write.history, metadata.write.rollback, metadata.form.command_button.write, metadata.form.command_button.verify, code.write, metadata.form.element.write_apply, metadata.write_learning.capture_before, metadata.write_learning.capture_after, metadata.write_learning.diff, metadata.write_learning.infer_rule, modules.search, modules.read, code.search, code.read, code.symbol.resolve, templates.bindings, diagnostics.call_chain, bulk.execute, changes.propose, storage.saved_state.apply_proposal, storage.saved_state.rollback, or mcp.job.get. Live database methods require payload.base_id; placeholders in examples must be replaced from project/user context.",
|
||||
"description": "Adapter method, for example metadata.objects.list, metadata.object.full, metadata.definition.find, metadata.route.resolve, extension.objects.find, templates.read, templates.analyze, templates.map, metadata.module_owner_cache.backfill, metadata.saved_state.ensure, metadata.saved_state.ensure.rollback, metadata.saved_state.prepare, metadata.saved_state.status, metadata.saved_state.diff, metadata.saved_state.changes.list, configuration.activation.status, configuration.activation.plan, configuration.activation.request, configuration.activation.request.status, configuration.activation.request.cancel, configuration.activation.audit, configuration.activation.capabilities, configuration.activation.bridge.probe, configuration.activation.execute, configuration.activation.verify, metadata.saved_state.forms.search, metadata.saved_state.modules.search, metadata.form.write_target.resolve, metadata.form.write_target.verify, metadata.module.write_apply, metadata.write.plan, metadata.write.preflight, metadata.write.capabilities, metadata.write, metadata.write.history, metadata.write.rollback, metadata.form.command_button.write, metadata.form.command_button.verify, code.write, metadata.form.element.write_apply, metadata.write_learning.capture_before, metadata.write_learning.capture_after, metadata.write_learning.diff, metadata.write_learning.infer_rule, modules.search, modules.read, code.search, code.read, code.symbol.resolve, templates.bindings, diagnostics.call_chain, bulk.execute, changes.propose, storage.saved_state.apply_proposal, storage.saved_state.rollback, or mcp.job.get. Live database methods require payload.base_id; placeholders in examples must be replaced from project/user context.",
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
@@ -641,6 +717,7 @@ TOOLS = [
|
||||
"path": {"type": "string"},
|
||||
"canonical_path": {"type": "string"},
|
||||
"extension": {"type": "string"},
|
||||
"module_ref": {"type": "string"},
|
||||
"routine_name": {"type": "string"},
|
||||
"routine_text": {"type": "string"},
|
||||
"module_text": {"type": "string"},
|
||||
@@ -648,6 +725,10 @@ TOOLS = [
|
||||
"code": {"type": "string"},
|
||||
"old": {"type": "string"},
|
||||
"new": {"type": "string"},
|
||||
"expected_sha1": {"type": "string"},
|
||||
"expected_text_sha1": {"type": "string"},
|
||||
"repository_lock": {"type": "object", "additionalProperties": True},
|
||||
"write_context": {"type": "object", "additionalProperties": True},
|
||||
"mode": {"type": "string", "enum": ["plan", "apply"]},
|
||||
"include_storage": {"type": "boolean"},
|
||||
},
|
||||
@@ -2180,7 +2261,10 @@ def _run_bulk_execute(payload: dict[str, Any], request_start: float, request_id:
|
||||
except AdapterError as exc:
|
||||
results.append({"index": index, "method": submethod, "status": "error", "error": str(exc), "diagnostics": adapter_error_result(submethod or "unknown", exc)})
|
||||
except Exception as exc:
|
||||
results.append({"index": index, "method": submethod, "status": "error", "error": str(exc), "traceback": traceback.format_exc(limit=5)})
|
||||
item = {"index": index, "method": submethod, "status": "error", "error": str(exc)}
|
||||
if truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
|
||||
item["traceback"] = traceback.format_exc(limit=5)
|
||||
results.append(item)
|
||||
|
||||
requested_count = len(_as_list(payload.get("requests")))
|
||||
failed_count = len([item for item in results if (item.get("status") in {"error", "invalid_argument"})])
|
||||
@@ -2269,6 +2353,9 @@ def enrich_result_with_freshness(payload: dict[str, Any], method: str, result: A
|
||||
if not isinstance(result, dict):
|
||||
return result
|
||||
context = build_freshness_context(payload)
|
||||
if method.startswith("storage."):
|
||||
context["cache_policy"] = "none"
|
||||
context["force_refresh"] = True
|
||||
context["request_id"] = str(payload.get("_mcp_request_id") or uuid.uuid4().hex)
|
||||
context["method"] = method
|
||||
context["base_id"] = payload.get("base_id")
|
||||
@@ -2310,6 +2397,12 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
|
||||
source_state = "working"
|
||||
cache_policy = "none"
|
||||
force_refresh = truthy(payload.get("force_refresh"))
|
||||
if method.startswith("storage."):
|
||||
# Storage methods always call the live SQL layer or adapter-local
|
||||
# backup store directly; their result is never served from the
|
||||
# metadata/vector cache.
|
||||
cache_policy = "none"
|
||||
force_refresh = True
|
||||
transformed = dict(payload)
|
||||
transformed["source_mode"] = source_mode
|
||||
transformed["source_state"] = source_state
|
||||
@@ -2343,7 +2436,39 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
|
||||
return transformed
|
||||
|
||||
|
||||
def http_json(method: str, path: str, payload: dict[str, Any] | None = None, timeout: float | None = None) -> Any:
|
||||
def mcp_audit_event(event: dict[str, Any]) -> None:
|
||||
"""Persist proxy telemetry without BSL text, payload bytes, or credentials."""
|
||||
try:
|
||||
path = Path(os.environ.get("ONEC_MCP_AUDIT_LOG_PATH") or "/data/mcp-audit.jsonl")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with MCP_AUDIT_LOCK:
|
||||
max_bytes = max(1_048_576, int(os.environ.get("ONEC_MCP_AUDIT_MAX_BYTES") or 52_428_800))
|
||||
keep_files = max(1, min(20, int(os.environ.get("ONEC_MCP_AUDIT_KEEP_FILES") or 10)))
|
||||
if path.exists() and path.stat().st_size >= max_bytes:
|
||||
for index in range(keep_files - 1, 0, -1):
|
||||
source = path.with_name(f"{path.name}.{index}")
|
||||
target = path.with_name(f"{path.name}.{index + 1}")
|
||||
if source.exists():
|
||||
source.replace(target)
|
||||
path.replace(path.with_name(f"{path.name}.1"))
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True, default=str) + "\n")
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
|
||||
|
||||
def mcp_audit_request_summary(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
keys = ("base_id", "extension", "extension_guid", "ref", "kind", "name", "object_type", "object_name", "module_ordinal", "mode")
|
||||
return {key: payload.get(key) for key in keys if payload.get(key) not in {None, ""}}
|
||||
|
||||
|
||||
def http_json(
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> Any:
|
||||
url = f"{adapter_url()}{path}"
|
||||
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers = {"Accept": "application/json"}
|
||||
@@ -2351,6 +2476,8 @@ def http_json(method: str, path: str, payload: dict[str, Any] | None = None, tim
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
if adapter_token():
|
||||
headers["Authorization"] = f"Bearer {adapter_token()}"
|
||||
if request_id and re.fullmatch(r"[A-Za-z0-9_.-]{8,128}", request_id):
|
||||
headers["X-Request-ID"] = request_id
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
effective_timeout = adapter_timeout() if timeout is None else timeout
|
||||
try:
|
||||
@@ -2362,33 +2489,69 @@ def http_json(method: str, path: str, payload: dict[str, Any] | None = None, tim
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise AdapterError(f"REST adapter returned HTTP {exc.code}", status=exc.code, body=body) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise AdapterError(f"REST adapter is unavailable: {exc.reason}") from exc
|
||||
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
|
||||
reason = getattr(exc, "reason", None) or str(exc) or type(exc).__name__
|
||||
raise AdapterError(f"REST adapter is unavailable: {reason}") from exc
|
||||
|
||||
|
||||
def call_adapter_method(method: str, payload: dict[str, Any], *, timeout: float | None = None) -> Any:
|
||||
request_id = str(payload.get("_mcp_request_id") or "").strip()
|
||||
started = now_ts()
|
||||
try:
|
||||
if method == "health":
|
||||
query = ""
|
||||
if payload.get("base_id"):
|
||||
query = "?" + urllib.parse.urlencode({"base_id": str(payload.get("base_id"))})
|
||||
return http_json("GET", f"/health{query}", timeout=timeout)
|
||||
if method == "help.methods":
|
||||
result = http_json("GET", f"/health{query}", timeout=timeout, request_id=request_id)
|
||||
elif method == "help.methods":
|
||||
try:
|
||||
return http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout)
|
||||
result = http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout, request_id=request_id)
|
||||
except AdapterError as exc:
|
||||
if exc.status not in {404, 405}:
|
||||
raise
|
||||
return http_json("GET", "/methods", timeout=timeout)
|
||||
return http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout)
|
||||
result = http_json("GET", "/methods", timeout=timeout, request_id=request_id)
|
||||
else:
|
||||
result = http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout, request_id=request_id)
|
||||
except Exception as exc:
|
||||
mcp_audit_event({
|
||||
"event": "mcp_adapter_call", "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"request_id": request_id or None, "method": method, "request": mcp_audit_request_summary(payload),
|
||||
"status": "exception", "error": "adapter_unavailable" if isinstance(exc, AdapterError) else "mcp_request_exception",
|
||||
"exception_type": type(exc).__name__, "duration_ms": int((now_ts() - started) * 1000),
|
||||
})
|
||||
raise
|
||||
mcp_audit_event({
|
||||
"event": "mcp_adapter_call", "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"request_id": request_id or None, "method": method, "request": mcp_audit_request_summary(payload),
|
||||
"status": result.get("status") if isinstance(result, dict) else None,
|
||||
"error": result.get("error") if isinstance(result, dict) else None,
|
||||
"duration_ms": int((now_ts() - started) * 1000),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def public_error(method: str, error: str, diagnostics: Any | None = None, *, schema: str = "adapter_1c_mcp_error.v1") -> dict[str, Any]:
|
||||
safe_diagnostics = diagnostics if diagnostics is not None else {"message": error}
|
||||
if not truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
|
||||
safe_diagnostics = strip_private_error_diagnostics(safe_diagnostics)
|
||||
return {
|
||||
"schema": schema,
|
||||
"status": "error",
|
||||
"method": method,
|
||||
"error": error,
|
||||
"diagnostics": diagnostics if diagnostics is not None else {"message": error},
|
||||
"diagnostics": safe_diagnostics,
|
||||
}
|
||||
|
||||
|
||||
def strip_private_error_diagnostics(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [strip_private_error_diagnostics(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
return {
|
||||
key: strip_private_error_diagnostics(item)
|
||||
for key, item in value.items()
|
||||
if key not in {"traceback", "stack", "stacktrace", "exception_repr"}
|
||||
}
|
||||
|
||||
|
||||
@@ -2934,15 +3097,36 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[
|
||||
has_code_edit = any(payload.get(field) is not None for field in code_fields)
|
||||
if not has_code_edit or target_kind not in {"module", "bsl_module", "bsl"}:
|
||||
return None
|
||||
owner_object_type = (
|
||||
payload.get("object_type")
|
||||
or target.get("object_type")
|
||||
or payload.get("owner_kind")
|
||||
or target.get("owner_kind")
|
||||
)
|
||||
if str(owner_object_type or "").strip().casefold() in {"module", "bsl_module", "bsl", "модуль"}:
|
||||
owner_object_type = None
|
||||
owner_object_name = (
|
||||
payload.get("object_name")
|
||||
or target.get("object_name")
|
||||
or payload.get("owner_name")
|
||||
or target.get("owner_name")
|
||||
)
|
||||
owner_object_guid = (
|
||||
payload.get("object_guid")
|
||||
or target.get("object_guid")
|
||||
or payload.get("owner_guid")
|
||||
or target.get("owner_guid")
|
||||
)
|
||||
suggested_payload = {
|
||||
"base_id": payload.get("base_id"),
|
||||
**{
|
||||
key: value
|
||||
for key, value in {
|
||||
"ref": payload.get("ref") or target.get("ref"),
|
||||
"object_type": payload.get("object_type") or target.get("object_type") or target.get("kind"),
|
||||
"object_name": payload.get("object_name") or target.get("object_name") or target.get("name"),
|
||||
"object_guid": payload.get("object_guid") or target.get("object_guid") or target.get("guid"),
|
||||
"module_ref": payload.get("module_ref") or target.get("module_ref"),
|
||||
"object_type": owner_object_type,
|
||||
"object_name": owner_object_name,
|
||||
"object_guid": owner_object_guid,
|
||||
"routine_name": payload.get("routine_name") or target.get("routine_name"),
|
||||
"routine_text": payload.get("routine_text"),
|
||||
"module_text": payload.get("module_text"),
|
||||
@@ -2968,6 +3152,125 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[
|
||||
}
|
||||
|
||||
|
||||
def purge_expired_selector_tokens() -> None:
|
||||
cutoff = now_ts() - SELECTOR_TOKEN_TTL_SECONDS
|
||||
with SELECTOR_TOKEN_LOCK:
|
||||
expired = [token for token, entry in SELECTOR_TOKENS.items() if float(entry.get("created_at") or 0) < cutoff]
|
||||
for token in expired:
|
||||
SELECTOR_TOKENS.pop(token, None)
|
||||
|
||||
|
||||
def issue_selector_token(selector: dict[str, Any]) -> str:
|
||||
purge_expired_selector_tokens()
|
||||
token = f"onecsel_{uuid.uuid4().hex}"
|
||||
with SELECTOR_TOKEN_LOCK:
|
||||
SELECTOR_TOKENS[token] = {"created_at": now_ts(), "selector": dict(selector)}
|
||||
return token
|
||||
|
||||
|
||||
def diagnostic_mode_authorized(payload: dict[str, Any]) -> bool:
|
||||
"""Developer diagnostics are opt-in at deployment level, not an agent choice."""
|
||||
return (
|
||||
(truthy(payload.get("diagnostic")) or truthy(payload.get("_allow_diagnostic")))
|
||||
and truthy(os.environ.get("ONEC_MCP_ALLOW_DIAGNOSTIC"))
|
||||
)
|
||||
|
||||
|
||||
def publicize_read_selectors(value: Any) -> Any:
|
||||
"""Replace adapter-issued technical continuations with short-lived opaque tokens."""
|
||||
if isinstance(value, list):
|
||||
return [publicize_read_selectors(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
public: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
if key in TECHNICAL_AGENT_FIELDS:
|
||||
continue
|
||||
if key == "read_selector" and isinstance(item, dict) and str(item.get("method") or "").strip():
|
||||
public[key] = {"method": str(item["method"]), "selector_token": issue_selector_token(item)}
|
||||
elif key == "read_selectors" and isinstance(item, dict):
|
||||
public[key] = {
|
||||
name: (
|
||||
{"method": str(selector["method"]), "selector_token": issue_selector_token(selector)}
|
||||
if isinstance(selector, dict) and str(selector.get("method") or "").strip()
|
||||
else publicize_read_selectors(selector)
|
||||
)
|
||||
for name, selector in item.items()
|
||||
}
|
||||
else:
|
||||
public[key] = publicize_read_selectors(item)
|
||||
return public
|
||||
|
||||
|
||||
def resolve_selector_token(method: str, payload: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
token = str(payload.get("selector_token") or "").strip()
|
||||
if not token:
|
||||
return payload, None
|
||||
purge_expired_selector_tokens()
|
||||
with SELECTOR_TOKEN_LOCK:
|
||||
entry = SELECTOR_TOKENS.get(token)
|
||||
selector = entry.get("selector") if isinstance(entry, dict) and isinstance(entry.get("selector"), dict) else None
|
||||
if not selector:
|
||||
return None, public_error(method, "selector_token_invalid", {"message": "selector_token is unknown or expired; repeat the public discovery call."})
|
||||
selector_method = str(selector.get("method") or "").strip()
|
||||
if selector_method != method:
|
||||
return None, public_error(method, "selector_token_method_mismatch", {"message": f"selector_token is valid only for `{selector_method}`."})
|
||||
explicit = {key: value for key, value in payload.items() if key != "selector_token"}
|
||||
resolved = {**selector, **explicit, "_selector_token_resolved": True}
|
||||
return resolved, None
|
||||
|
||||
|
||||
def technical_selector_fields(payload: Any) -> list[str]:
|
||||
"""Find technical selector keys at every JSON level supplied by an agent."""
|
||||
found: set[str] = set()
|
||||
if isinstance(payload, dict):
|
||||
for key, value in payload.items():
|
||||
if key in TECHNICAL_AGENT_FIELDS:
|
||||
found.add(key)
|
||||
found.update(technical_selector_fields(value))
|
||||
elif isinstance(payload, list):
|
||||
for value in payload:
|
||||
found.update(technical_selector_fields(value))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def normal_agent_technical_field_guardrail(method: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if diagnostic_mode_authorized(payload) or truthy(payload.get("_selector_token_resolved")):
|
||||
return None
|
||||
prohibited = technical_selector_fields(payload)
|
||||
if not prohibited:
|
||||
return None
|
||||
return {
|
||||
"schema": "adapter_1c_mcp_policy.v1",
|
||||
"status": "blocked",
|
||||
"method": method,
|
||||
"reason": "technical_selector_forbidden",
|
||||
"diagnostics": {
|
||||
"fields": prohibited,
|
||||
"message": "Use complete public 1C names (extension + ref + child name) or an adapter-issued selector_token. SQL/storage coordinates are developer diagnostics only.",
|
||||
"suggested_request": {
|
||||
"method": "metadata.object.full",
|
||||
"payload": {"base_id": payload.get("base_id"), "ref": payload.get("ref"), "configuration_view": "effective_working"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def runtime_form_inspection_unsupported(method: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if method not in {"runtime.form.elements.inspect", "runtime.form.inspect"}:
|
||||
return None
|
||||
return {
|
||||
"schema": "onec_runtime_form_inspection.v1",
|
||||
"status": "unsupported",
|
||||
"method": method,
|
||||
"error": "runtime_inspection_unsupported",
|
||||
"base_id": payload.get("base_id"),
|
||||
"diagnostics": {
|
||||
"message": "The SQL-only adapter does not open 1C forms, execute form handlers, or inspect runtime-generated controls. Read static metadata with metadata.form.decode; obtain runtime evidence through a separately authorised human-operated channel.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
|
||||
request_start = now_ts()
|
||||
request_id = uuid.uuid4().hex
|
||||
@@ -2986,14 +3289,18 @@ def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
|
||||
}
|
||||
request_payload["_mcp_request_id"] = request_id
|
||||
payload = request_payload
|
||||
runtime_guardrail = runtime_form_inspection_unsupported(method, payload)
|
||||
if runtime_guardrail is not None:
|
||||
return enrich_result_with_freshness(payload, method, runtime_guardrail, request_start)
|
||||
technical_field_guardrail = normal_agent_technical_field_guardrail(method, payload)
|
||||
if technical_field_guardrail is not None:
|
||||
return enrich_result_with_freshness(payload, method, technical_field_guardrail, request_start)
|
||||
code_guardrail = metadata_write_code_guardrail(method, payload)
|
||||
if code_guardrail is not None:
|
||||
return enrich_result_with_freshness(payload, method, code_guardrail, request_start)
|
||||
if method_requires_base_id(method) and not str(payload.get("base_id") or "").strip():
|
||||
return missing_base_id_policy(method)
|
||||
if (method.startswith(DIAGNOSTIC_METHOD_PREFIXES) or method in DIAGNOSTIC_METHODS) and not (
|
||||
truthy(payload.get("diagnostic")) or truthy(payload.get("_allow_diagnostic"))
|
||||
):
|
||||
if (method.startswith(DIAGNOSTIC_METHOD_PREFIXES) or method in DIAGNOSTIC_METHODS) and not diagnostic_mode_authorized(payload):
|
||||
return {
|
||||
"schema": "adapter_1c_mcp_policy.v1",
|
||||
"status": "blocked",
|
||||
@@ -3005,7 +3312,7 @@ def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
|
||||
"Use metadata.object.attributes, metadata.object.full, metadata.object.forms, metadata.form.decode, "
|
||||
"metadata.resolve_overrides, code.search, code.read, modules.search, metadata.definition.find, templates.bindings, "
|
||||
"or modules.read. "
|
||||
"Pass diagnostic=true only for explicit adapter diagnostics."
|
||||
"Developer diagnostics require diagnostic=true and ONEC_MCP_ALLOW_DIAGNOSTIC=true in the MCP deployment."
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -3343,6 +3650,10 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
|
||||
payload = args.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
return tool_text(public_error(method or "onec_request", "invalid_payload", {"message": "payload must be an object"}))
|
||||
payload, selector_error = resolve_selector_token(method, payload)
|
||||
if selector_error is not None:
|
||||
return tool_text(selector_error)
|
||||
assert payload is not None
|
||||
if method in {"mcp.job.get", "adapter.job.get", "onec.job.get"}:
|
||||
job_id = str(payload.get("job_id") or "").strip()
|
||||
if not job_id:
|
||||
@@ -3356,7 +3667,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
|
||||
):
|
||||
job = dict(job)
|
||||
job["result"] = maybe_enrich_owner_fields(str(job.get("method") or "").strip(), job.get("payload"), job.get("result"))
|
||||
return tool_text(job)
|
||||
return tool_text(publicize_read_selectors(job))
|
||||
except AdapterError as exc:
|
||||
return tool_text(adapter_error_result("adapter.job.get", exc))
|
||||
if method in {"mcp.job.cancel", "adapter.job.cancel", "onec.job.cancel"}:
|
||||
@@ -3367,7 +3678,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
|
||||
return tool_text(call_adapter_method("adapter.job.cancel", {"job_id": job_id}))
|
||||
except AdapterError as exc:
|
||||
return tool_text(adapter_error_result("adapter.job.cancel", exc))
|
||||
return tool_text(run_or_enqueue_adapter_method(method, payload))
|
||||
return tool_text(publicize_read_selectors(run_or_enqueue_adapter_method(method, payload)))
|
||||
if name == "onec_job_get":
|
||||
job_id = str(args.get("job_id") or "").strip()
|
||||
if not job_id:
|
||||
@@ -3381,7 +3692,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
|
||||
):
|
||||
job = dict(job)
|
||||
job["result"] = maybe_enrich_owner_fields(str(job.get("method") or "").strip(), job.get("payload"), job.get("result"))
|
||||
return tool_text(job)
|
||||
return tool_text(publicize_read_selectors(job))
|
||||
except AdapterError as exc:
|
||||
return tool_text(adapter_error_result("adapter.job.get", exc))
|
||||
if name == "onec_job_cancel":
|
||||
@@ -3460,7 +3771,10 @@ def handle_jsonrpc(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return jsonrpc_result(request_id, handle_tool_call(str(params.get("name") or ""), params.get("arguments") or {}))
|
||||
return jsonrpc_error(request_id, -32601, f"Method not found: {method}")
|
||||
except Exception as exc:
|
||||
return jsonrpc_error(request_id, -32000, str(exc), traceback.format_exc())
|
||||
data: dict[str, Any] = {"message": str(exc)}
|
||||
if truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
|
||||
data["traceback"] = traceback.format_exc()
|
||||
return jsonrpc_error(request_id, -32000, "MCP request failed", data)
|
||||
|
||||
|
||||
def payload_has_method(payload: Any, method: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Small periodic summary for MCP-to-REST availability telemetry."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
path = Path("/data/mcp-audit.jsonl")
|
||||
rows: list[dict] = []
|
||||
malformed_rows = 0
|
||||
if path.exists():
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed_rows += 1
|
||||
continue
|
||||
if row.get("event") == "mcp_adapter_call":
|
||||
rows.append(row)
|
||||
failures = [row for row in rows if row.get("error") or row.get("status") == "exception"]
|
||||
availability = [row for row in failures if row.get("error") == "adapter_unavailable"]
|
||||
print(json.dumps({
|
||||
"schema": "onec_mcp_audit_summary.v1", "status": "ok" if path.exists() else "log_not_found",
|
||||
"events": len(rows), "malformed_rows": malformed_rows,
|
||||
"bases": dict(Counter(str((row.get("request") or {}).get("base_id") or "<none>") for row in rows)),
|
||||
"failures": len(failures),
|
||||
"failure_methods": dict(Counter(str(row.get("method") or "<none>") for row in failures)),
|
||||
"recent_failures": failures[-20:],
|
||||
"findings": [
|
||||
*([{"priority": "P1", "kind": "rest_unavailable_from_mcp", "count": len(availability), "next_action": "Check MCP-to-REST connectivity, then find the same request_id in REST telemetry if it exists."}] if availability else []),
|
||||
*([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect proxy container restarts and log rotation."}] if malformed_rows else []),
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY observer_server.py /app/observer_server.py
|
||||
COPY web /app/web
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV ONEC_OBSERVER_HOST=0.0.0.0
|
||||
ENV ONEC_OBSERVER_PORT=8031
|
||||
ENV ONEC_OBSERVER_AUDIT_DIR=/audit
|
||||
|
||||
EXPOSE 8031
|
||||
CMD ["python", "/app/observer_server.py"]
|
||||
@@ -0,0 +1,14 @@
|
||||
# Adapter Observer service
|
||||
|
||||
Standalone read-only analytics service for `adapter-1c` operational telemetry.
|
||||
|
||||
Run locally with:
|
||||
|
||||
```text
|
||||
ONEC_OBSERVER_AUDIT_DIR=<directory-with-adapter-audit.jsonl> python observer_server.py
|
||||
```
|
||||
|
||||
The production compose definition and operational contract are in
|
||||
`core/deploy/docker/adapter-observer/` and
|
||||
`docs/runbooks/adapter-observer.md` respectively. Do not make this service a
|
||||
dependency of the adapter or give it SQL credentials.
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Read-only operational observer for adapter-1c audit telemetry.
|
||||
|
||||
This service never connects to 1C SQL storage and never mutates adapter data.
|
||||
It reads the adapter's privacy-safe rotated JSONL files from a read-only mount.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from collections import Counter, defaultdict
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
WEB_ROOT = ROOT / "web"
|
||||
AUDIT_DIR = Path(os.environ.get("ONEC_OBSERVER_AUDIT_DIR", "/audit"))
|
||||
MCP_AUDIT_DIR = Path(os.environ.get("ONEC_OBSERVER_MCP_AUDIT_DIR", "/mcp-audit"))
|
||||
STATE_DIR = Path(os.environ.get("ONEC_OBSERVER_STATE_DIR", "/state"))
|
||||
ADAPTER_URL = os.environ.get("ONEC_OBSERVER_ADAPTER_URL", "").rstrip("/")
|
||||
HOST = os.environ.get("ONEC_OBSERVER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("ONEC_OBSERVER_PORT", "8031"))
|
||||
MAX_ROWS = 10000
|
||||
|
||||
|
||||
def number(value: object) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
AUTO_COVERAGE_BASE = os.environ.get("ONEC_OBSERVER_COVERAGE_BASE_ID", "upo_test")
|
||||
AUTO_COVERAGE_INTERVAL = max(300, number(os.environ.get("ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS", "900")))
|
||||
LAST_COVERAGE: dict[str, object] = {"status": "not_started"}
|
||||
|
||||
|
||||
def percentile(values: list[int], q: float) -> int:
|
||||
if not values:
|
||||
return 0
|
||||
ordered = sorted(values)
|
||||
index = max(0, min(len(ordered) - 1, math.ceil(len(ordered) * q) - 1))
|
||||
return ordered[index]
|
||||
|
||||
|
||||
def audit_files(directory: Path, prefix: str) -> list[Path]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
paths = [p for p in directory.glob(f"{prefix}*") if p.is_file()]
|
||||
return sorted(paths, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def read_events(directory: Path = AUDIT_DIR, prefix: str = "adapter-audit.jsonl", event_name: str = "adapter_rpc") -> tuple[list[dict], int]:
|
||||
events: list[dict] = []
|
||||
malformed = 0
|
||||
for path in audit_files(directory, prefix):
|
||||
try:
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed += 1
|
||||
continue
|
||||
if isinstance(row, dict) and row.get("event") == event_name:
|
||||
events.append(row)
|
||||
except OSError:
|
||||
continue
|
||||
return events[-MAX_ROWS:], malformed
|
||||
|
||||
|
||||
def event_view(row: dict, source: str = "rest") -> dict:
|
||||
request = row.get("request") if isinstance(row.get("request"), dict) else {}
|
||||
return {
|
||||
"source": source, "time": row.get("time"), "request_id": row.get("request_id"),
|
||||
"method": row.get("method"), "base_id": request.get("base_id"),
|
||||
"selector": {key: request.get(key) for key in ("ref", "kind", "name", "object_type", "object_name", "extension", "mode", "execution_mode") if request.get(key) not in (None, "")},
|
||||
"status": row.get("status") or "unknown", "error": row.get("error") or "",
|
||||
"exception_type": row.get("exception_type") or "", "duration_ms": number(row.get("duration_ms")),
|
||||
"result_duration_ms": row.get("result_duration_ms"),
|
||||
"result_summary": row.get("result_summary") if isinstance(row.get("result_summary"), dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def correlations(rest: list[dict], mcp: list[dict]) -> list[dict]:
|
||||
rest_by_id = {str(row.get("request_id")): row for row in rest if row.get("request_id")}
|
||||
rows = []
|
||||
for row in reversed(mcp):
|
||||
request_id = str(row.get("request_id") or "")
|
||||
if not request_id:
|
||||
continue
|
||||
rest_row = rest_by_id.get(request_id)
|
||||
mcp_view = event_view(row, "mcp")
|
||||
rows.append({"request_id": request_id, "mcp": mcp_view, "rest": event_view(rest_row, "rest") if rest_row else None, "correlation_status": "matched" if rest_row else "not_reached_rest"})
|
||||
return rows[:1000]
|
||||
|
||||
|
||||
def adapter_rpc(method: str, payload: dict) -> dict:
|
||||
if not ADAPTER_URL:
|
||||
raise RuntimeError("adapter_url_not_configured")
|
||||
request = Request(f"{ADAPTER_URL}/rpc", data=json.dumps({"method": method, "payload": payload}).encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, method="POST")
|
||||
try:
|
||||
with urlopen(request, timeout=45) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
except (HTTPError, URLError, TimeoutError) as exc:
|
||||
raise RuntimeError(f"adapter_read_failed:{type(exc).__name__}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError("adapter_response_not_object")
|
||||
return value
|
||||
|
||||
|
||||
def coverage_snapshot(base_id: str) -> dict:
|
||||
methods = adapter_rpc("help.methods", {})
|
||||
audit = adapter_rpc("metadata.adapter.audit", {"base_id": base_id, "include_missing": True, "include_unmapped": True, "timeout_seconds": 45})
|
||||
snapshot = {"schema": "onec_adapter_observer_coverage.v1", "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "base_id": base_id, "methods": methods.get("methods") or [], "audit": audit}
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
latest = STATE_DIR / f"coverage-{base_id}.json"
|
||||
previous = json.loads(latest.read_text(encoding="utf-8")) if latest.exists() else None
|
||||
if isinstance(previous, dict):
|
||||
old_methods = {str(item.get("name")) for item in previous.get("methods") or [] if isinstance(item, dict)}
|
||||
new_methods = {str(item.get("name")) for item in snapshot["methods"] if isinstance(item, dict)}
|
||||
def kind_counts(value: dict) -> dict[str, int]:
|
||||
audit_value = value.get("audit") if isinstance(value.get("audit"), dict) else {}
|
||||
return {str(item.get("kind")): number(item.get("count")) for item in audit_value.get("metadata_kinds") or [] if isinstance(item, dict)}
|
||||
old_kinds, new_kinds = kind_counts(previous), kind_counts(snapshot)
|
||||
changed_kinds = [{"kind": kind, "before": old_kinds.get(kind, 0), "after": new_kinds.get(kind, 0)} for kind in sorted(set(old_kinds) | set(new_kinds)) if old_kinds.get(kind, 0) != new_kinds.get(kind, 0)]
|
||||
def unresolved(value: dict) -> set[str]:
|
||||
audit_value = value.get("audit") if isinstance(value.get("audit"), dict) else {}
|
||||
return {json.dumps(item, ensure_ascii=False, sort_keys=True) if isinstance(item, dict) else str(item) for item in audit_value.get("not_yet_decoded") or []}
|
||||
old_unresolved, new_unresolved = unresolved(previous), unresolved(snapshot)
|
||||
snapshot["comparison"] = {"previous_captured_at": previous.get("captured_at"), "methods_added": sorted(new_methods - old_methods), "methods_removed": sorted(old_methods - new_methods), "kind_count_changes": changed_kinds, "undecoded_added": sorted(new_unresolved - old_unresolved), "undecoded_removed": sorted(old_unresolved - new_unresolved)}
|
||||
temporary = STATE_DIR / "coverage-latest.json.tmp"
|
||||
temporary.write_text(json.dumps(snapshot, ensure_ascii=False), encoding="utf-8")
|
||||
temporary.replace(latest)
|
||||
history_path = STATE_DIR / f"coverage-{base_id}.history.jsonl"
|
||||
history = history_path.read_text(encoding="utf-8", errors="replace").splitlines()[-49:] if history_path.exists() else []
|
||||
history.append(json.dumps(snapshot, ensure_ascii=False))
|
||||
history_path.write_text("\n".join(history) + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
snapshot["persistence_status"] = "unavailable"
|
||||
return snapshot
|
||||
|
||||
|
||||
def coverage_worker() -> None:
|
||||
"""Best-effort periodic read-only snapshot; failure must not stop the UI."""
|
||||
while True:
|
||||
try:
|
||||
snapshot = coverage_snapshot(AUTO_COVERAGE_BASE)
|
||||
LAST_COVERAGE.update({"status": "ok", "captured_at": snapshot.get("captured_at"), "base_id": AUTO_COVERAGE_BASE})
|
||||
except RuntimeError as exc:
|
||||
LAST_COVERAGE.update({"status": "error", "base_id": AUTO_COVERAGE_BASE, "error": str(exc), "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
|
||||
time.sleep(AUTO_COVERAGE_INTERVAL)
|
||||
|
||||
|
||||
def recommendation(event: dict) -> str:
|
||||
error = str(event.get("error") or "")
|
||||
status = str(event.get("status") or "")
|
||||
if error == "time_budget_exhausted":
|
||||
return "Сузить публичный selector (ref, форма или routine) либо выполнить тяжёлую операцию как job."
|
||||
if error == "public_write_route_unresolved":
|
||||
return "Передать request_id и resolver summary разработчикам адаптера; не подбирать storage coordinates вручную."
|
||||
if error == "ambiguous_fragment":
|
||||
return "Уточнить routine_name или заменить модуль целиком; фрагмент не должен подбираться по совпадению."
|
||||
if error == "base_id_required":
|
||||
return "Передать base_id из списка сконфигурированных баз; не пытаться подставлять SQL-параметры."
|
||||
if status in {"unsupported", "blocked", "invalid_argument"}:
|
||||
return "Это безопасная остановка. Проверить публичный контракт метода и next_action в результате."
|
||||
if status == "exception":
|
||||
return "Найти совпадающий request_id в REST/MCP telemetry и воспроизвести только на upo_test."
|
||||
return "Повторить read-операцию с тем же публичным selector-ом и сравнить длительность/статус."
|
||||
|
||||
|
||||
def build_summary(events: list[dict], malformed: int) -> dict:
|
||||
durations = [number(row.get("duration_ms")) for row in events]
|
||||
failures = [row for row in events if str(row.get("status")) == "exception"]
|
||||
groups: dict[tuple[str, str, str], list[dict]] = defaultdict(list)
|
||||
for row in events:
|
||||
groups[(str(row.get("method") or "<none>"), str(row.get("status") or "unknown"), str(row.get("error") or ""))].append(row)
|
||||
findings = []
|
||||
normal_lifecycle = {"ok", "accepted", "running", "done", "cancelled", "not_found", "unknown"}
|
||||
for (method, status, error), rows in sorted(groups.items(), key=lambda item: len(item[1]), reverse=True):
|
||||
if status in normal_lifecycle and not error:
|
||||
continue
|
||||
finding = event_view(rows[-1])
|
||||
findings.append({"method": method, "status": status, "error": error, "count": len(rows), "last_request_id": finding["request_id"], "recommendation": recommendation(finding)})
|
||||
if len(findings) >= 20:
|
||||
break
|
||||
per_method: dict[str, list[int]] = defaultdict(list)
|
||||
for row in events:
|
||||
per_method[str(row.get("method") or "<none>")].append(number(row.get("duration_ms")))
|
||||
methods = [{"method": name, "calls": len(values), "p50_ms": percentile(values, .5), "p95_ms": percentile(values, .95), "max_ms": max(values)} for name, values in per_method.items()]
|
||||
slow = sorted((event_view(row) for row in events if number(row.get("duration_ms")) >= 5000), key=lambda row: row["duration_ms"], reverse=True)[:20]
|
||||
return {"schema": "onec_adapter_observer_summary.v1", "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "events": len(events), "malformed_rows": malformed, "exceptions": len(failures), "p50_ms": percentile(durations, .5), "p95_ms": percentile(durations, .95), "max_ms": max(durations, default=0), "methods": sorted(methods, key=lambda row: row["p95_ms"], reverse=True), "findings": findings, "slow_events": slow}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "AdapterObserver/1.0"
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def send_json(self, status: int, value: object) -> None:
|
||||
body = json.dumps(value, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def serve_file(self, relative: str) -> None:
|
||||
target = (WEB_ROOT / relative).resolve()
|
||||
if WEB_ROOT not in target.parents and target != WEB_ROOT or not target.is_file():
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
body = target.read_bytes()
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", mimetypes.guess_type(str(target))[0] or "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/health":
|
||||
self.send_json(200, {"status": "ok", "service": "adapter-observer", "audit_dir": str(AUDIT_DIR), "files": [path.name for path in audit_files(AUDIT_DIR, "adapter-audit.jsonl")], "mcp_files": [path.name for path in audit_files(MCP_AUDIT_DIR, "mcp-audit.jsonl")], "coverage": LAST_COVERAGE})
|
||||
return
|
||||
events, malformed = read_events()
|
||||
mcp_events, mcp_malformed = read_events(MCP_AUDIT_DIR, "mcp-audit.jsonl", "mcp_adapter_call")
|
||||
if parsed.path == "/api/summary":
|
||||
self.send_json(200, build_summary(events, malformed))
|
||||
return
|
||||
if parsed.path == "/api/events":
|
||||
query = parse_qs(parsed.query)
|
||||
method, status, base_id = query.get("method", [""])[0], query.get("status", [""])[0], query.get("base_id", [""])[0]
|
||||
minimum_duration = number(query.get("min_duration_ms", [0])[0])
|
||||
since, until = query.get("since", [""])[0], query.get("until", [""])[0]
|
||||
rows = [event_view(row) for row in reversed(events)]
|
||||
if method: rows = [row for row in rows if row["method"] == method]
|
||||
if status: rows = [row for row in rows if row["status"] == status]
|
||||
if base_id: rows = [row for row in rows if row["base_id"] == base_id]
|
||||
if minimum_duration: rows = [row for row in rows if row["duration_ms"] >= minimum_duration]
|
||||
if since: rows = [row for row in rows if str(row.get("time") or "") >= since]
|
||||
if until: rows = [row for row in rows if str(row.get("time") or "") <= until]
|
||||
limit = min(max(number(query.get("limit", [200])[0]), 1), 1000)
|
||||
self.send_json(200, {"schema": "onec_adapter_observer_events.v1", "events": rows[:limit], "total": len(rows), "malformed_rows": malformed})
|
||||
return
|
||||
if parsed.path == "/api/mcp-events":
|
||||
self.send_json(200, {"schema": "onec_adapter_observer_mcp_events.v1", "events": [event_view(row, "mcp") for row in reversed(mcp_events[-1000:])], "total": len(mcp_events), "malformed_rows": mcp_malformed})
|
||||
return
|
||||
if parsed.path == "/api/correlations":
|
||||
self.send_json(200, {"schema": "onec_adapter_observer_correlations.v1", "correlations": correlations(events, mcp_events), "mcp_events": len(mcp_events), "mcp_malformed_rows": mcp_malformed})
|
||||
return
|
||||
if parsed.path == "/api/coverage":
|
||||
base_id = parse_qs(parsed.query).get("base_id", ["upo_test"])[0]
|
||||
if not re.fullmatch(r"[A-Za-zА-Яа-яЁё0-9_.-]{1,80}", base_id):
|
||||
self.send_json(400, {"error": "invalid_base_id"})
|
||||
return
|
||||
try:
|
||||
self.send_json(200, coverage_snapshot(base_id))
|
||||
except RuntimeError as exc:
|
||||
self.send_json(502, {"error": str(exc)})
|
||||
return
|
||||
if parsed.path == "/api/objects":
|
||||
query = parse_qs(parsed.query)
|
||||
base_id, kind = query.get("base_id", ["upo"])[0], query.get("kind", [""])[0]
|
||||
if not kind:
|
||||
self.send_json(400, {"error": "kind_required"})
|
||||
return
|
||||
try:
|
||||
started = time.monotonic()
|
||||
result = adapter_rpc("metadata.objects.list", {"base_id": base_id, "kind": kind, "limit": min(max(number(query.get("limit", [200])[0]), 1), 1000), "offset": max(number(query.get("offset", [0])[0]), 0), "refresh_cache": True, "exact_counts": True})
|
||||
self.send_json(200, {**result, "observer": {"duration_ms": int((time.monotonic() - started) * 1000), "method": "metadata.objects.list"}})
|
||||
except RuntimeError as exc:
|
||||
self.send_json(502, {"error": str(exc)})
|
||||
return
|
||||
if parsed.path == "/api/object":
|
||||
query = parse_qs(parsed.query)
|
||||
base_id, ref = query.get("base_id", ["upo"])[0], query.get("ref", [""])[0]
|
||||
if not ref:
|
||||
self.send_json(400, {"error": "ref_required"})
|
||||
return
|
||||
started = time.monotonic()
|
||||
sections = {}
|
||||
for name, method in (("attributes", "metadata.object.attributes"), ("forms", "metadata.object.forms"), ("modules", "metadata.object.modules"), ("templates", "metadata.object.templates")):
|
||||
section_started = time.monotonic()
|
||||
try:
|
||||
result = adapter_rpc(method, {"base_id": base_id, "ref": ref})
|
||||
sections[name] = {"status": result.get("status", "unknown"), "data": result, "duration_ms": int((time.monotonic() - section_started) * 1000), "method": method}
|
||||
except RuntimeError as exc:
|
||||
sections[name] = {"status": "error", "error": str(exc), "duration_ms": int((time.monotonic() - section_started) * 1000), "method": method}
|
||||
self.send_json(200, {"schema": "onec_adapter_observer_object_node.v1", "base_id": base_id, "ref": ref, "status": "ok", "duration_ms": int((time.monotonic() - started) * 1000), "sections": sections})
|
||||
return
|
||||
if parsed.path == "/api/object/action":
|
||||
query = parse_qs(parsed.query)
|
||||
base_id, ref = query.get("base_id", ["upo"])[0], query.get("ref", [""])[0]
|
||||
action = query.get("action", [""])[0]
|
||||
actions = {
|
||||
"card": "metadata.object.get", "properties": "metadata.object.properties",
|
||||
"attributes": "metadata.object.attributes", "forms": "metadata.object.forms",
|
||||
"commands": "metadata.object.commands", "modules": "metadata.object.modules",
|
||||
"templates": "metadata.object.templates", "related": "metadata.object.related",
|
||||
}
|
||||
if not ref:
|
||||
self.send_json(400, {"error": "ref_required"})
|
||||
return
|
||||
if action not in actions:
|
||||
self.send_json(400, {"error": "unsupported_action", "supported_actions": list(actions)})
|
||||
return
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = adapter_rpc(actions[action], {"base_id": base_id, "ref": ref})
|
||||
self.send_json(200, {**result, "observer": {"duration_ms": int((time.monotonic() - started) * 1000), "method": actions[action]}})
|
||||
except RuntimeError as exc:
|
||||
self.send_json(502, {"error": str(exc)})
|
||||
return
|
||||
if parsed.path in {"/", "/index.html"}:
|
||||
self.serve_file("index.html")
|
||||
return
|
||||
if parsed.path.startswith("/assets/"):
|
||||
self.serve_file(parsed.path.lstrip("/"))
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_HEAD(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
known = parsed.path in {"/", "/index.html", "/health", "/api/summary", "/api/events", "/api/mcp-events", "/api/correlations", "/api/coverage", "/api/objects", "/api/object", "/api/object/action"} or parsed.path.startswith("/assets/")
|
||||
self.send_response(HTTPStatus.OK if known else HTTPStatus.NOT_FOUND)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if ADAPTER_URL:
|
||||
threading.Thread(target=coverage_worker, name="coverage-snapshot", daemon=True).start()
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,54 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
let allEvents = [];
|
||||
|
||||
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
||||
const duration = (value) => {
|
||||
const seconds = Math.max(0, Number(value || 0) / 1000);
|
||||
const format = (number) => Number(number.toFixed(1)).toString().replace(".", ",");
|
||||
if (seconds < 60) return `${format(seconds)} с`;
|
||||
const minutes = Math.floor(seconds / 60), restSeconds = seconds - minutes * 60;
|
||||
if (minutes < 60) return `${minutes} мин ${format(restSeconds)} с`;
|
||||
return `${Math.floor(minutes / 60)} ч ${minutes % 60} мин ${format(restSeconds)} с`;
|
||||
};
|
||||
async function api(path) { const response = await fetch(path); if (!response.ok) throw new Error(await response.text()); return response.json(); }
|
||||
function displayDetails(value) { if (Array.isArray(value)) return value.map(displayDetails); if (!value || typeof value !== "object") return value; return Object.fromEntries(Object.entries(value).map(([key, item]) => /^(duration|result_duration|p50|p95|max)_ms$/.test(key) ? [key.slice(0, -3), duration(item)] : [key, displayDetails(item)])); }
|
||||
function showDetails(row) { const received = row.received || row, observer = row.observer || received.observer || {}, method = row.action || observer.method || received.method || "Детали"; $("#detail-title").textContent = actionLabels?.[method] || method; $("#detail-meta").textContent = [row.ref || received.ref || received.base_id, received.status, observer.duration_ms === undefined ? "" : duration(observer.duration_ms)].filter(Boolean).join(" · "); $("#detail-json").textContent = JSON.stringify(displayDetails(row), null, 2); $("#detail").showModal(); }
|
||||
|
||||
function renderEvents() {
|
||||
const method = $("#method").value.trim(), status = $("#status").value, base = $("#base").value.trim(), minimum = Number($("#min-duration").value || 0) * 1000, since = $("#since").value, until = $("#until").value;
|
||||
const rows = allEvents.filter((event) => (!method || event.method.includes(method)) && (!status || event.status === status) && (!base || event.base_id === base) && event.duration_ms >= minimum && (!since || event.time >= since) && (!until || event.time <= until));
|
||||
$("#events").innerHTML = rows.map((event, index) => `<tr><td class="muted">${esc(event.time)}</td><td><b>${esc(event.method)}</b><br><span class="muted">${esc(event.selector.ref || event.selector.object_name || event.base_id || "—")}</span></td><td class="status ${esc(event.status)}">${esc(event.status)}</td><td>${duration(event.duration_ms)}</td><td>${esc(event.error || event.exception_type || "—")}</td><td><button data-row="${index}">Детали</button></td></tr>`).join("") || '<tr><td colspan="6" class="muted">Запросов по выбранному фильтру нет.</td></tr>';
|
||||
$("#events").querySelectorAll("button").forEach((button) => { button.onclick = () => showDetails(rows[Number(button.dataset.row)]); });
|
||||
}
|
||||
|
||||
const treeGroups = [["Общие",["CommonModule","CommonForm","CommonCommand","CommonAttribute","CommonPicture","CommonTemplate","Constant","DefinedType","Role","Subsystem","ScheduledJob","EventSubscription","FunctionalOption","SessionParameter"]],["Справочники",["Catalog"]],["Документы",["Document","DocumentJournal","DocumentNumerator","Sequence"]],["Перечисления",["Enum"]],["Отчёты и обработки",["Report","DataProcessor"]],["Планы",["ChartOfAccounts","ChartOfCharacteristicTypes","ChartOfCalculationTypes","ExchangePlan"]],["Регистры",["InformationRegister","AccumulationRegister","AccountingRegister","CalculationRegister"]],["Бизнес-процессы и задачи",["BusinessProcess","Task"]],["Сервисы и интеграции",["WebService","HTTPService","IntegrationService","ExternalDataSource","XDTOPackage"]]];
|
||||
const catalogActions = [["card", "Карточка"], ["properties", "Свойства"], ["attributes", "Реквизиты"], ["forms", "Формы"], ["commands", "Команды"], ["modules", "Модули"], ["templates", "Макеты"], ["related", "Связи"]];
|
||||
const actionLabels = Object.fromEntries(catalogActions);
|
||||
document.addEventListener("click", (event) => { const button = event.target.closest("summary button[data-kind]"); if (button) { event.preventDefault(); button.closest("details").open = true; } }, true);
|
||||
function objectActions(base, object) { const ref = object.ref || `Catalog.${object.name}`; return `<span class="object-actions">${catalogActions.map(([action, label]) => `<button data-action="${action}" data-base="${esc(base)}" data-ref="${esc(ref)}">${label}</button>`).join("")}</span>`; }
|
||||
async function runObjectAction(button) { const { action, base, ref } = button.dataset; button.disabled = true; const label = button.textContent; button.textContent = "…"; try { const result = await api(`/api/object/action?base_id=${encodeURIComponent(base)}&ref=${encodeURIComponent(ref)}&action=${encodeURIComponent(action)}`); showDetails({ action, ref, duration: duration(result.observer?.duration_ms), received: result }); } catch (error) { showDetails({ action, ref, error: error.message }); } finally { button.disabled = false; button.textContent = label; } }
|
||||
function bindObjectActions(root) { root.querySelectorAll("button[data-action]").forEach((button) => { button.onclick = () => runObjectAction(button); }); if (!root.querySelector(".object-actions")) return; const search = document.createElement("input"), status = document.createElement("small"), rows = [...root.querySelectorAll(".object-row")]; search.className = "catalog-search"; search.type = "search"; search.placeholder = "Найти справочник"; search.setAttribute("aria-label", "Найти справочник"); status.className = "catalog-filter-status muted"; root.prepend(status); root.prepend(search); const filter = () => { const query = search.value.trim().toLocaleLowerCase(); let visible = 0; rows.forEach((row) => { const matches = !query || row.textContent.toLocaleLowerCase().includes(query); row.hidden = !matches; if (matches) visible += 1; }); status.textContent = `Показано: ${visible} из ${rows.length}`; }; search.oninput = filter; filter(); }
|
||||
async function loadAllKindObjects(base, kind) {
|
||||
const pageSize = 1000, first = await api(`/api/objects?base_id=${encodeURIComponent(base)}&kind=${encodeURIComponent(kind)}&limit=${pageSize}&offset=0`), objects = [...(first.objects || [])], total = Number(first.counts?.total_visible ?? first.counts?.total ?? objects.length);
|
||||
let elapsed = Number(first.observer?.duration_ms || 0);
|
||||
for (let offset = objects.length; offset < total; offset += pageSize) { const page = await api(`/api/objects?base_id=${encodeURIComponent(base)}&kind=${encodeURIComponent(kind)}&limit=${pageSize}&offset=${offset}`), rows = page.objects || []; elapsed += Number(page.observer?.duration_ms || 0); objects.push(...rows); if (!rows.length) break; }
|
||||
return { ...first, objects, observer: { ...(first.observer || {}), duration_ms: elapsed } };
|
||||
}
|
||||
async function tree() {
|
||||
const root = $("#tree-result"), base = $("#tree-base").value.trim() || "upo"; root.textContent = "Читаю структуру конфигурации…";
|
||||
try {
|
||||
const data = await api(`/api/coverage?base_id=${encodeURIComponent(base)}`), kinds = (data.audit || {}).metadata_kinds || [], byKind = Object.fromEntries(kinds.map((x) => [x.kind, x]));
|
||||
root.innerHTML = `<h2>Конфигурация: ${esc(base)}</h2>${treeGroups.map(([title, names]) => { const rows = names.map((name) => byKind[name]).filter(Boolean); if (!rows.length) return ""; if (rows.length === 1) { const item = rows[0]; return `<details class="finding"><summary><b>▸ ${title}</b> · ${item.count || 0} объектов · <button data-kind="${esc(item.kind)}">↻ Читать</button></summary><div id="kind-${esc(item.kind)}"></div></details>`; } return `<details class="finding"><summary><b>▸ ${title}</b> · ${rows.reduce((sum, item) => sum + Number(item.count || 0), 0)} объектов</summary>${rows.map((item) => `<div> ├─ ${esc(item.kind_ru || item.kind)} · ${item.count || 0} <button data-kind="${esc(item.kind)}">↻ Читать</button><div id="kind-${esc(item.kind)}"></div></div>`).join("")}</details>`; }).join("")}`;
|
||||
root.querySelectorAll("button[data-kind]").forEach((button) => { button.onclick = async () => { const box = $("#kind-" + button.dataset.kind); box.textContent = "⏳ чтение всех записей…"; try { const result = await loadAllKindObjects(base, button.dataset.kind), objects = result.objects || [], observer = result.observer || {}; box.innerHTML = `<small class="muted">${objects.length} объектов · ${duration(observer.duration_ms)} · ${observer.method || ""} · <button class="node-log">Журнал</button></small>${objects.map((object) => `<div class="object-row"> └─ <span>${esc(object.name || object.ref || "—")}</span>${button.dataset.kind === "Catalog" ? objectActions(base, object) : ""}</div>`).join("") || "Нет объектов."}`; box.querySelector(".node-log").onclick = () => showDetails({ received: result, observer }); bindObjectActions(box); } catch (error) { box.textContent = `Не удалось загрузить объекты: ${error.message}`; } }; });
|
||||
} catch (error) { root.textContent = error.message; }
|
||||
}
|
||||
|
||||
function renderSummary(summary) { $("#cards").innerHTML = [["Запросов", summary.events], ["Исключений", summary.exceptions], ["p50", duration(summary.p50_ms)], ["p95", duration(summary.p95_ms)]].map(([label, value]) => `<div class="card">${label}<b>${value}</b></div>`).join(""); const maximum = Math.max(...summary.methods.map((item) => item.p95_ms), 1); $("#methods").innerHTML = summary.methods.slice(0, 12).map((item) => `<div class="bar"><span>${esc(item.method)} <small class="muted">${item.calls}</small></span><i style="width:${Math.max(2, item.p95_ms / maximum * 100)}%"></i><span>${duration(item.p95_ms)}</span></div>`).join(""); const findings = summary.findings.map((item) => `<div class="finding"><b>${esc(item.method)}</b> · ${esc(item.status)} · ${item.count} раз<br><span class="muted">${esc(item.error || "без кода")}</span><p>${esc(item.recommendation)}</p></div>`).join("") || '<p class="muted">Отклонений нет.</p>'; const slow = (summary.slow_events || []).map((item) => `<div class="finding"><b>${esc(item.method)}</b> · ${duration(item.duration_ms)} · <span class="muted">${esc(item.request_id || "—")}</span><br>${esc(item.selector.ref || item.selector.object_name || item.base_id || "без selector-а")}</div>`).join("") || '<p class="muted">Нет.</p>'; $("#findings").innerHTML = `${findings}<h2>Выбросы ≥ 5 сек</h2>${slow}`; }
|
||||
function renderCorrelations(data) { $("#correlations").innerHTML = data.correlations.slice(0, 250).map((item) => `<tr><td class="muted">${esc(item.request_id)}</td><td>${esc(item.mcp.method)}</td><td class="${esc(item.mcp.status)}">${esc(item.mcp.status)}</td><td class="${item.rest ? "ok" : "exception"}">${item.rest ? esc(item.rest.status) : "не достиг REST"}</td><td>${duration(item.mcp.duration_ms)}${item.rest ? ` / ${duration(item.rest.duration_ms)}` : ""}</td></tr>`).join("") || '<tr><td colspan="5" class="muted">Коррелируемых событий нет.</td></tr>'; }
|
||||
async function openKind(kind, baseId) { const root = $("#object-list"); root.textContent = `Загружаю ${kind}…`; try { const data = await api(`/api/objects?base_id=${encodeURIComponent(baseId)}&kind=${encodeURIComponent(kind)}`), objects = data.objects || []; root.innerHTML = `<h2>${esc(kind)} · ${objects.length}</h2><div class="table-wrap"><table><thead><tr><th>Объект</th><th>Синоним</th><th>Происхождение</th></tr></thead><tbody>${objects.map((item) => `<tr><td>${esc(item.name || item.ref || "—")}</td><td>${esc(item.synonym || "—")}</td><td>${esc((item.origin || {}).source || "—")}</td></tr>`).join("") || '<tr><td colspan="3" class="muted">Объектов нет.</td></tr>'}</tbody></table></div>`; } catch (error) { root.textContent = `Не удалось загрузить объекты: ${error.message}`; } }
|
||||
async function coverage() { const root = $("#coverage-result"); root.textContent = "Читаю контракт и coverage snapshot…"; try { const baseId = $("#coverage-base").value.trim() || "upo", data = await api(`/api/coverage?base_id=${encodeURIComponent(baseId)}`), audit = data.audit || {}; if (audit.status && audit.status !== "ok") { root.innerHTML = `<div class="finding"><b>База недоступна для metadata coverage: ${esc(audit.status)}</b><p>Это не нулевое покрытие.</p></div>`; return; } const kinds = audit.metadata_kinds || []; root.innerHTML = `<h2>База ${esc(baseId)} → типы метаданных</h2><div class="table-wrap"><table><thead><tr><th>Тип</th><th>Объектов</th><th></th></tr></thead><tbody>${kinds.map((item) => `<tr><td>└ ${esc(item.kind || "—")}</td><td>${esc(item.count || 0)}</td><td><button class="open-kind" data-kind="${esc(item.kind)}">Открыть</button></td></tr>`).join("")}</tbody></table></div><div id="object-list" class="muted"></div>`; root.querySelectorAll(".open-kind").forEach((button) => { button.onclick = () => openKind(button.dataset.kind, baseId); }); } catch (error) { root.textContent = `Не удалось получить coverage: ${error.message}`; } }
|
||||
async function load() { const [events, summary, correlations] = await Promise.all([api("/api/events?limit=500"), api("/api/summary"), api("/api/correlations")]); allEvents = events.events; renderEvents(); renderSummary(summary); renderCorrelations(correlations); }
|
||||
document.querySelectorAll(".tab").forEach((button) => { button.onclick = () => { document.querySelectorAll(".tab,.panel").forEach((node) => node.classList.remove("active")); button.classList.add("active"); $(`#${button.dataset.tab}`).classList.add("active"); }; });
|
||||
["method", "base", "min-duration", "since", "until"].forEach((id) => { $(`#${id}`).oninput = renderEvents; });
|
||||
$("#status").onchange = renderEvents; $("#refresh").onclick = load; $("#load-coverage").onclick = coverage; $("#close").onclick = () => $("#detail").close(); $("#load-tree").onclick = tree;
|
||||
load().catch((error) => { $("#events").innerHTML = `<tr><td colspan="6">Ошибка загрузки: ${esc(error.message)}</td></tr>`; });
|
||||
@@ -0,0 +1,13 @@
|
||||
:root{--ink:#e8edf1;--muted:#8f9ba6;--ground:#11161a;--panel:#182126;--line:#2b383f;--accent:#65d0b0;--warn:#f0bc65;--bad:#f27f77;--radius:7px}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--ground);color:var(--ink);font:14px ui-monospace,"Cascadia Code",monospace}
|
||||
header{min-height:92px;padding:22px max(24px,5vw);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:#141b1f}
|
||||
h1{margin:0;font:600 27px Georgia,serif;letter-spacing:.02em}.eyebrow{margin:0 0 5px;color:var(--accent);font-size:11px;letter-spacing:.12em}
|
||||
button,input,select{font:inherit;color:inherit;background:#202c31;border:1px solid var(--line);border-radius:5px;padding:9px 11px}button{cursor:pointer}button:hover,.tab.active{border-color:var(--accent);color:var(--accent)}button:disabled{cursor:wait;opacity:.65}
|
||||
main{max-width:1500px;margin:auto;padding:22px}nav{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:14px}.panel{display:none;padding-top:20px}.panel.active{display:block}.filters{display:flex;gap:10px;margin-bottom:14px}.filters input{min-width:280px}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius)}table{width:100%;border-collapse:collapse}th{text-align:left;color:var(--muted);font-weight:400;background:#141b1f}th,td{padding:11px 12px;border-bottom:1px solid #243137;vertical-align:top}tr:last-child td{border:0}
|
||||
.status{font-size:12px}.ok{color:var(--accent)}.partial,.blocked,.unsupported,.invalid_argument{color:var(--warn)}.exception{color:var(--bad)}.muted{color:var(--muted)}
|
||||
.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.card{background:var(--panel);border-left:3px solid var(--accent);padding:16px;border-radius:0 var(--radius) var(--radius) 0}.card b{font-size:25px;display:block;margin-top:7px}h2{font:600 18px Georgia,serif;margin:30px 0 12px}.bar{display:grid;grid-template-columns:220px 1fr 80px;gap:12px;align-items:center;margin:8px 0}.bar i{height:8px;background:linear-gradient(90deg,var(--accent),var(--warn));display:block}.finding{padding:12px;border:1px solid var(--line);margin:8px 0;background:var(--panel)}
|
||||
.catalog-search{display:block;width:min(460px,100%);margin:10px 0;padding:7px 9px}.object-row{display:flex;align-items:flex-start;gap:10px;padding:5px 0}.object-actions{display:flex;flex-wrap:wrap;gap:5px}.object-actions button{padding:4px 7px;font-size:11px}
|
||||
dialog{width:min(850px,94vw);color:var(--ink);background:#11181c;border:1px solid var(--accent);border-radius:var(--radius)}dialog pre{white-space:pre-wrap;overflow:auto;max-height:70vh}dialog button{float:right}
|
||||
@media(max-width:720px){main{padding:14px}.cards{grid-template-columns:repeat(2,1fr)}.bar{grid-template-columns:1fr}.filters input{min-width:0;width:100%}header{padding:18px}.filters{flex-direction:column}.object-row{display:block}.object-actions{margin:6px 0 0 18px}}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Adapter Observer</title><link rel="stylesheet" href="/assets/style.css"></head>
|
||||
<body><header><div><p class="eyebrow">1C / SQL-ONLY / READ-ONLY</p><h1>Adapter Observer</h1></div><button id="refresh">Обновить</button></header>
|
||||
<main><nav><button class="tab active" data-tab="journal">Журнал запросов</button><button class="tab" data-tab="tree">Дерево объектов</button><button class="tab" data-tab="analytics">Аналитика</button><button class="tab" data-tab="transport">MCP ↔ REST</button><button class="tab" data-tab="coverage">Покрытие</button></nav>
|
||||
<section id="journal" class="panel active"><div class="filters"><input id="method" placeholder="Метод"><input id="base" placeholder="base_id"><input id="min-duration" type="number" min="0" step="0.1" placeholder="Мин. длительность, с"><input id="since" type="datetime-local" title="С"><input id="until" type="datetime-local" title="По"><select id="status"><option value="">Все статусы</option><option>ok</option><option>partial</option><option>blocked</option><option>unsupported</option><option>invalid_argument</option><option>exception</option></select></div><div class="table-wrap"><table><thead><tr><th>Время</th><th>Метод / объект</th><th>Статус</th><th>Длительность</th><th>Причина</th><th></th></tr></thead><tbody id="events"></tbody></table></div></section>
|
||||
<section id="tree" class="panel"><div class="filters"><input id="tree-base" value="upo" placeholder="base_id"><button id="load-tree">Загрузить базу</button></div><div id="tree-result" class="muted">База ещё не загружена.</div></section>
|
||||
<section id="analytics" class="panel"><div id="cards" class="cards"></div><h2>Медленные методы</h2><div id="methods"></div><h2>Требуют внимания</h2><div id="findings"></div></section>
|
||||
<section id="transport" class="panel"><h2>Корреляция транспорта</h2><p class="muted">MCP-событие без REST-пары означает, что вызов не дошёл до адаптера.</p><div class="table-wrap"><table><thead><tr><th>request_id</th><th>Метод</th><th>MCP</th><th>REST</th><th>Время</th></tr></thead><tbody id="correlations"></tbody></table></div></section>
|
||||
<section id="coverage" class="panel"><div class="filters"><input id="coverage-base" value="upo" placeholder="base_id"><button id="load-coverage">Обновить снимок</button></div><div id="coverage-result" class="muted">Снимок ещё не загружен.</div></section></main>
|
||||
<dialog id="detail"><button id="close" aria-label="Закрыть">×</button><h2 id="detail-title">Детали</h2><p id="detail-meta" class="muted"></p><pre id="detail-json"></pre></dialog><script src="/assets/app.js"></script></body></html>
|
||||
@@ -25,6 +25,11 @@ def normalized_text_sha1(text: str) -> str:
|
||||
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _text_with_line_ending(text: str, line_ending: str) -> str:
|
||||
"""Normalize caller text first, then render it in a stream's convention."""
|
||||
return str(text or "").replace("\r\n", "\n").replace("\r", "\n").replace("\n", line_ending)
|
||||
|
||||
|
||||
def decode_text(data: bytes) -> tuple[str | None, str | None]:
|
||||
if data.startswith(b"\xef\xbb\xbf"):
|
||||
try:
|
||||
@@ -121,6 +126,188 @@ def stream_blocks_with_data(payload: bytes, *, limit: int = 100) -> list[dict[st
|
||||
return blocks
|
||||
|
||||
|
||||
def structural_stream_blocks_with_data(payload: bytes, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""Read one contiguous stream chain without matching headers inside data.
|
||||
|
||||
Some 1C stream payloads legitimately contain the ASCII sequence used by a
|
||||
stream header inside a binary member. ``stream_blocks_with_data`` remains
|
||||
a discovery heuristic for legacy readers; this function follows only the
|
||||
next header located exactly at the previous member's end and is suitable
|
||||
for evidence-bearing module decoding.
|
||||
"""
|
||||
blocks: list[dict[str, Any]] = []
|
||||
first = STREAM_HEADER_RE.search(payload)
|
||||
if first is None:
|
||||
return blocks
|
||||
match = first
|
||||
while match is not None and len(blocks) < limit:
|
||||
declared_1 = int(match.group(1), 16)
|
||||
declared_2 = int(match.group(2), 16)
|
||||
data_offset = match.end()
|
||||
data_end = data_offset + declared_2
|
||||
if declared_2 <= 0 or data_end > len(payload):
|
||||
break
|
||||
data = payload[data_offset:data_end]
|
||||
text, encoding = decode_text(data)
|
||||
blocks.append(
|
||||
{
|
||||
"header_offset": match.start(),
|
||||
"header_end": match.end(),
|
||||
"data_offset": data_offset,
|
||||
"data_end": data_end,
|
||||
"declared_1": declared_1,
|
||||
"declared_2": declared_2,
|
||||
"bytes": len(data),
|
||||
"sha1": sha1_hex(data),
|
||||
"encoding": encoding,
|
||||
"text": text,
|
||||
"data": data,
|
||||
"structural": True,
|
||||
}
|
||||
)
|
||||
match = STREAM_HEADER_RE.match(payload, data_end)
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_structural_stream_blocks(payload: bytes, *, include_text: bool = False, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""Public structural stream view with the same shape as discovery blocks."""
|
||||
result: list[dict[str, Any]] = []
|
||||
for block in structural_stream_blocks_with_data(payload, limit=limit):
|
||||
text = str(block.get("text") or "")
|
||||
clean = text.replace("\x00", "")
|
||||
item = {
|
||||
**{key: value for key, value in block.items() if key not in {"text", "data"}},
|
||||
"text_preview": clean[:500],
|
||||
"has_bsl_marker": bool(text and any(marker in clean for marker in BSL_MARKERS)),
|
||||
"has_html_marker": bool(text and any(marker in clean for marker in HTML_MARKERS)),
|
||||
}
|
||||
if include_text:
|
||||
item["text"] = block.get("text")
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def decode_declared_utf8_bsl_prefix(payload: bytes, stream_index: int) -> dict[str, Any]:
|
||||
"""Decode a BSL prefix whose byte length is declared by a stream header.
|
||||
|
||||
Object-module containers observed in 1C keep the editable UTF-8 BSL bytes
|
||||
in the first ``declared_1`` bytes of a fixed-size member. The remaining
|
||||
member bytes are opaque platform metadata, not source text. This helper
|
||||
is read-only evidence; it intentionally does not construct replacements.
|
||||
"""
|
||||
blocks = structural_stream_blocks_with_data(payload)
|
||||
if stream_index < 0 or stream_index >= len(blocks):
|
||||
return {"status": "not_found", "error": "stream_index_not_found"}
|
||||
block = blocks[stream_index]
|
||||
data = bytes(block["data"])
|
||||
prefix_bytes = int(block["declared_1"])
|
||||
if prefix_bytes <= 0 or prefix_bytes > len(data):
|
||||
return {
|
||||
"status": "unsupported",
|
||||
"error": "invalid_declared_bsl_prefix_length",
|
||||
"declared_1": prefix_bytes,
|
||||
"member_bytes": len(data),
|
||||
}
|
||||
prefix = data[:prefix_bytes]
|
||||
if not prefix.startswith(b"\xef\xbb\xbf"):
|
||||
return {
|
||||
"status": "unsupported",
|
||||
"error": "declared_bsl_prefix_not_utf8_bom",
|
||||
"declared_1": prefix_bytes,
|
||||
"member_bytes": len(data),
|
||||
}
|
||||
try:
|
||||
text = prefix.decode("utf-8-sig", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
return {"status": "unsupported", "error": "declared_bsl_prefix_decode_error", "diagnostics": {"message": str(exc)}}
|
||||
return {
|
||||
"status": "ok",
|
||||
"text": text,
|
||||
"stream_index": stream_index,
|
||||
"header_offset": block["header_offset"],
|
||||
"data_offset": block["data_offset"],
|
||||
"bsl_prefix_bytes": prefix_bytes,
|
||||
"opaque_tail_bytes": len(data) - prefix_bytes,
|
||||
"member_bytes": len(data),
|
||||
"text_sha1": normalized_text_sha1(text),
|
||||
"structural": True,
|
||||
}
|
||||
|
||||
|
||||
def replace_declared_utf8_bsl_prefix_same_width(
|
||||
payload: bytes,
|
||||
stream_index: int,
|
||||
*,
|
||||
text: str,
|
||||
expected_text_sha1: str | None = None,
|
||||
) -> tuple[bytes, dict[str, Any]]:
|
||||
"""Replace a proven fixed-width BSL prefix without touching its tail.
|
||||
|
||||
This is deliberately narrower than ``replace_stream_block``. The report
|
||||
object-module carrier has a fixed-size stream member whose first declared
|
||||
bytes are UTF-8 source and whose remaining bytes are opaque. A shorter
|
||||
source is right-padded with spaces *inside the declared source field*;
|
||||
longer source is rejected. Consequently the member, every following
|
||||
stream, and the opaque tail stay byte-for-byte identical.
|
||||
|
||||
It does not attempt to synthesize the platform's independent version
|
||||
atoms. The caller remains responsible for the proven paired
|
||||
``__configinfo`` SHA-1 update.
|
||||
"""
|
||||
decoded = decode_declared_utf8_bsl_prefix(payload, stream_index)
|
||||
if decoded.get("status") != "ok":
|
||||
raise ValueError(str(decoded.get("error") or "declared_bsl_prefix_unavailable"))
|
||||
old_text = str(decoded["text"])
|
||||
if not is_declared_utf8_bsl_source(old_text):
|
||||
raise ValueError("declared_bsl_prefix_is_not_bsl_source")
|
||||
old_sha1 = normalized_text_sha1(old_text)
|
||||
if expected_text_sha1 and expected_text_sha1.lower() != old_sha1:
|
||||
raise ValueError("expected_text_sha1 does not match declared BSL prefix")
|
||||
line_ending = "\r\n" if "\r\n" in old_text else "\r" if "\r" in old_text else "\n"
|
||||
rendered = _text_with_line_ending(text, line_ending)
|
||||
encoded = b"\xef\xbb\xbf" + rendered.encode("utf-8")
|
||||
prefix_bytes = int(decoded["bsl_prefix_bytes"])
|
||||
if len(encoded) > prefix_bytes:
|
||||
raise ValueError("replacement_declared_bsl_prefix_exceeds_fixed_width")
|
||||
# BSL whitespace outside string literals is semantically inert. Padding
|
||||
# is restricted to the fixed source field and is observable in readback.
|
||||
padded = encoded + (b" " * (prefix_bytes - len(encoded)))
|
||||
if len(padded) != prefix_bytes:
|
||||
raise AssertionError("declared BSL prefix width changed")
|
||||
data_offset = int(decoded["data_offset"])
|
||||
new_payload = payload[:data_offset] + padded + payload[data_offset + prefix_bytes :]
|
||||
if payload[data_offset + prefix_bytes :] != new_payload[data_offset + prefix_bytes :]:
|
||||
raise AssertionError("opaque member tail changed")
|
||||
return new_payload, {
|
||||
"stream_index": stream_index,
|
||||
"mode": "declared_utf8_bsl_prefix_same_width",
|
||||
"old_text_sha1": old_sha1,
|
||||
"new_text_sha1": normalized_text_sha1(rendered),
|
||||
"old_bsl_prefix_bytes": prefix_bytes,
|
||||
"new_bsl_source_bytes": len(encoded),
|
||||
"padding_bytes": prefix_bytes - len(encoded),
|
||||
"opaque_tail_bytes": int(decoded["opaque_tail_bytes"]),
|
||||
"opaque_tail_preserved": True,
|
||||
"old_text_preview": old_text[:500],
|
||||
"new_text_preview": rendered[:500],
|
||||
}
|
||||
|
||||
|
||||
def is_declared_utf8_bsl_source(text: str) -> bool:
|
||||
"""Recognize source evidence in a declared UTF-8 stream prefix.
|
||||
|
||||
A module may legitimately consist solely of comments, while other stream
|
||||
members can also have a UTF-8 prefix (for example a brace descriptor).
|
||||
The prefix is BSL evidence only when it has a normal BSL marker or every
|
||||
nonblank source line is a BSL line comment.
|
||||
"""
|
||||
source = str(text or "").lstrip("\ufeff")
|
||||
if any(marker in source for marker in BSL_MARKERS):
|
||||
return True
|
||||
lines = [line.strip() for line in source.replace("\r\n", "\n").replace("\r", "\n").split("\n") if line.strip()]
|
||||
return bool(lines) and all(line.startswith("//") for line in lines)
|
||||
|
||||
|
||||
def stream_header(size: int) -> bytes:
|
||||
if size < 0 or size > 0xFFFFFFFF:
|
||||
raise ValueError("stream size is outside 8-hex header range")
|
||||
@@ -159,9 +346,20 @@ def replace_stream_block(
|
||||
if not old:
|
||||
raise ValueError("replace.old is required")
|
||||
count = int(replace.get("count") or 1)
|
||||
if old not in old_text:
|
||||
# Public code.read normalizes BSL to LF while streams often retain
|
||||
# CRLF. Treat that representation difference as irrelevant, but do
|
||||
# not loosen matching of any other character (spaces/tabs remain
|
||||
# exact). The replacement is rendered back in the stream's original
|
||||
# line-ending convention to avoid unrelated formatting churn.
|
||||
line_ending = "\r\n" if "\r\n" in old_text else "\r" if "\r" in old_text else "\n"
|
||||
source_old = old
|
||||
source_new = _text_with_line_ending(new, line_ending) if ("\n" in new or "\r" in new) else new
|
||||
if source_old not in old_text:
|
||||
source_old = _text_with_line_ending(old, line_ending)
|
||||
source_new = _text_with_line_ending(new, line_ending)
|
||||
if source_old not in old_text:
|
||||
raise ValueError("replace.old was not found in stream text")
|
||||
text = old_text.replace(old, new, count)
|
||||
text = old_text.replace(source_old, source_new, count)
|
||||
routine_edit = None
|
||||
if routine is not None:
|
||||
if old_text is None:
|
||||
@@ -261,7 +459,29 @@ def classify_payload(data: bytes, *, include_text: bool = False, include_tree: b
|
||||
decoded = decode_payload_lossless(data)
|
||||
payload = decoded.get("payload") if isinstance(decoded.get("payload"), (bytes, bytearray)) else b""
|
||||
markers = payload_markers(bytes(payload))
|
||||
# Prefer proven contiguous boundaries for normal container decoding. Keep
|
||||
# the regex scan only as a discovery fallback for legacy irregular blobs.
|
||||
stream_blocks = extract_structural_stream_blocks(bytes(payload), include_text=include_text)
|
||||
if not stream_blocks and "stream_headers" in markers:
|
||||
stream_blocks = extract_stream_blocks(bytes(payload), include_text=include_text)
|
||||
# A report object module observed in ConfigCAS stores source in the
|
||||
# declared UTF-8 prefix of a fixed-size stream member. The remainder is
|
||||
# opaque platform state and must never be exposed as BSL. Keep this as
|
||||
# read-only evidence: replacement still requires a separately proven
|
||||
# reverse codec for that carrier.
|
||||
if stream_blocks:
|
||||
for stream_index, stream in enumerate(stream_blocks):
|
||||
declared_prefix = decode_declared_utf8_bsl_prefix(bytes(payload), stream_index)
|
||||
if declared_prefix.get("status") != "ok" or not is_declared_utf8_bsl_source(str(declared_prefix.get("text") or "")):
|
||||
continue
|
||||
stream["declared_utf8_bsl_prefix"] = {
|
||||
key: declared_prefix[key]
|
||||
for key in ("bsl_prefix_bytes", "opaque_tail_bytes", "member_bytes", "text_sha1", "structural")
|
||||
if key in declared_prefix
|
||||
}
|
||||
if include_text:
|
||||
stream["text"] = declared_prefix["text"]
|
||||
stream["text_preview"] = str(declared_prefix["text"] or "").replace("\x00", "")[:500]
|
||||
text = decoded.get("text")
|
||||
tree = None
|
||||
root = None
|
||||
|
||||
@@ -2202,7 +2202,16 @@ def section_record_semantic_properties(row: dict[str, Any], parameters: list[dic
|
||||
mapped: set[int] = set()
|
||||
for index, (group, name) in SECTION_RECORD_SEMANTIC_PROPERTIES.items():
|
||||
mapped.add(index)
|
||||
add_grouped_property(groups, group, semantic_property(name, parameter_value(parameters, index), index=index))
|
||||
value = parameter_value(parameters, index)
|
||||
source = "form_payload"
|
||||
# A managed-form record can store the localized title outside its
|
||||
# direct parameter #3. The row decoder already resolves that exact
|
||||
# title path, so expose it instead of misleading an agent with an
|
||||
# empty semantic «Заголовок» beside a non-empty public row.title.
|
||||
if index == 3 and value in {None, ""} and row.get("title") not in {None, ""}:
|
||||
value = row.get("title")
|
||||
source = "form_payload_title_path"
|
||||
add_grouped_property(groups, group, semantic_property(name, value, index=index, source=source))
|
||||
if row.get("category"):
|
||||
add_grouped_property(groups, "Основные", semantic_property("Категория", row.get("category"), source="decoder"))
|
||||
add_grouped_property(groups, "Основные", semantic_property("Вид", row.get("category"), source="decoder"))
|
||||
@@ -3850,6 +3859,37 @@ def enrich_button_command_semantics(items: list[dict[str, Any]], links: list[dic
|
||||
)
|
||||
|
||||
|
||||
FORM_AUXILIARY_ITEM_TYPES = {
|
||||
"Контекстное меню", "Расширенная подсказка", "SearchStringAddition",
|
||||
"ViewStatusAddition", "SearchControlAddition",
|
||||
}
|
||||
|
||||
|
||||
def form_item_coverage_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Report semantic coverage without auxiliary form records hiding control quality."""
|
||||
buckets = {
|
||||
"all_items": {"items": 0, "mapped": 0, "unmapped": 0},
|
||||
"interactive_items": {"items": 0, "mapped": 0, "unmapped": 0},
|
||||
"auxiliary_items": {"items": 0, "mapped": 0, "unmapped": 0},
|
||||
}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
coverage = (item.get("semantic") or {}).get("coverage") if isinstance(item.get("semantic"), dict) else None
|
||||
if not isinstance(coverage, dict):
|
||||
continue
|
||||
target = "auxiliary_items" if str(item.get("type_name") or "") in FORM_AUXILIARY_ITEM_TYPES else "interactive_items"
|
||||
for bucket_name in ("all_items", target):
|
||||
bucket = buckets[bucket_name]
|
||||
bucket["items"] += 1
|
||||
bucket["mapped"] += int(coverage.get("mapped") or 0)
|
||||
bucket["unmapped"] += int(coverage.get("unmapped") or 0)
|
||||
for bucket in buckets.values():
|
||||
bucket["total"] = bucket["mapped"] + bucket["unmapped"]
|
||||
bucket["status"] = "partial" if bucket["unmapped"] else "ok"
|
||||
return buckets
|
||||
|
||||
|
||||
def decode_form_payload(
|
||||
tree: Any,
|
||||
*,
|
||||
@@ -3906,11 +3946,13 @@ def decode_form_payload(
|
||||
form_parameters = form_common_parameters(tree, limit=max_parameters)
|
||||
form_semantic = form_common_semantic(form_parameters, include_diagnostics=include_parameters)
|
||||
enrich_form_common_semantic(form_semantic, items)
|
||||
coverage_summary = form_item_coverage_summary(items)
|
||||
result = {
|
||||
"schema": "onec_form_payload_profile.v1",
|
||||
"status": "ok" if root.get("root_marker") == "4" else "not_form_payload",
|
||||
"root": root,
|
||||
"form_semantic": form_semantic,
|
||||
"item_coverage": coverage_summary,
|
||||
**({"form_parameters": form_parameters} if include_parameters else {}),
|
||||
"events": events,
|
||||
"items": items,
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Lossless read-only decoder for 1C Data Composition Schema SQL payloads.
|
||||
|
||||
The payload stored in ConfigCAS is commonly a compressed stream with a small
|
||||
binary prefix followed by an XML ``SchemaFile`` document. This module does
|
||||
not infer SCD semantics from names: every returned item is backed by an XML
|
||||
node in that document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
import xml.parsers.expat as expat
|
||||
import html
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .payload import decode_payload_lossless
|
||||
|
||||
|
||||
QUERY_PARAMETER_RE = re.compile(r"&([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)")
|
||||
QUERY_SOURCE_RE = re.compile(r"(?:\bИЗ|\bFROM|\bJOIN|\bСОЕДИНЕНИЕ)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*(?:\.[A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)+)", re.IGNORECASE)
|
||||
QUERY_SOURCE_BINDING_RE = re.compile(r"(?:\bИЗ|\bFROM|\bJOIN|\bСОЕДИНЕНИЕ)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*(?:\.[A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)+)(?:\s+(?:КАК|AS)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*))?", re.IGNORECASE)
|
||||
QUERY_SELECT_RE = re.compile(r"\b(?:ВЫБРАТЬ|SELECT)\b(.*?)(?=\b(?:ИЗ|FROM)\b)", re.IGNORECASE | re.DOTALL)
|
||||
QUERY_ALIAS_RE = re.compile(r"\b(?:КАК|AS)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)(?=\s*(?:,|\r?\n|$))", re.IGNORECASE)
|
||||
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||||
|
||||
|
||||
def direct_child(node: ET.Element, name: str) -> ET.Element | None:
|
||||
return next((child for child in node if local_name(child.tag) == name), None)
|
||||
|
||||
|
||||
def child_text(node: ET.Element, *names: str) -> str:
|
||||
for name in names:
|
||||
child = direct_child(node, name)
|
||||
if child is not None:
|
||||
value = "".join(child.itertext()).strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def query_without_line_comments(text: str) -> str:
|
||||
"""Remove 1C query ``//`` comments without touching quoted string literals."""
|
||||
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
quoted = False
|
||||
while index < len(text):
|
||||
char = text[index]
|
||||
if char == '"':
|
||||
result.append(char)
|
||||
if quoted and index + 1 < len(text) and text[index + 1] == '"':
|
||||
result.append('"')
|
||||
index += 2
|
||||
continue
|
||||
quoted = not quoted
|
||||
index += 1
|
||||
continue
|
||||
if not quoted and char == "/" and index + 1 < len(text) and text[index + 1] == "/":
|
||||
line_end = text.find("\n", index)
|
||||
if line_end < 0:
|
||||
break
|
||||
result.append("\n")
|
||||
index = line_end + 1
|
||||
continue
|
||||
result.append(char)
|
||||
index += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def node_path(root: ET.Element, target: ET.Element) -> str:
|
||||
"""Produce a stable, human-readable evidence path without XML prefixes."""
|
||||
|
||||
def visit(node: ET.Element, prefix: str) -> str | None:
|
||||
name = local_name(node.tag)
|
||||
current = f"{prefix}/{name}" if prefix else f"/{name}"
|
||||
if node is target:
|
||||
return current
|
||||
positions: dict[str, int] = {}
|
||||
for child in node:
|
||||
child_name = local_name(child.tag)
|
||||
positions[child_name] = positions.get(child_name, 0) + 1
|
||||
child_prefix = f"{current}[{positions[child_name]}]"
|
||||
found = visit(child, child_prefix)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
return visit(root, "") or "/"
|
||||
|
||||
|
||||
def xml_from_scd_payload(data: bytes) -> tuple[ET.Element | None, dict[str, Any]]:
|
||||
decoded = decode_payload_lossless(data)
|
||||
payload = decoded.get("payload")
|
||||
if not isinstance(payload, (bytes, bytearray)):
|
||||
return None, {"status": "undecodable", "code": "SCD_PAYLOAD_EMPTY"}
|
||||
raw = bytes(payload)
|
||||
start = raw.find(b"<?xml")
|
||||
if start < 0:
|
||||
start = raw.find(b"<SchemaFile")
|
||||
if start < 0:
|
||||
return None, {
|
||||
"status": "undecodable",
|
||||
"code": "SCD_XML_NOT_FOUND",
|
||||
"compression": decoded.get("compression"),
|
||||
"raw_bytes": decoded.get("raw_bytes"),
|
||||
"payload_bytes": decoded.get("payload_bytes"),
|
||||
}
|
||||
# 1C appends a binary trailer after the XML document in some releases.
|
||||
# ElementTree correctly rejects that trailer, so keep the exact XML range.
|
||||
end_marker = b"</SchemaFile>"
|
||||
end = raw.find(end_marker, start)
|
||||
xml_bytes = raw[start : end + len(end_marker)] if end >= 0 else raw[start:]
|
||||
try:
|
||||
root = ET.fromstring(xml_bytes.decode("utf-8-sig"))
|
||||
except (UnicodeDecodeError, ET.ParseError) as exc:
|
||||
return None, {
|
||||
"status": "undecodable",
|
||||
"code": "SCD_XML_INVALID",
|
||||
"message": str(exc),
|
||||
"compression": decoded.get("compression"),
|
||||
"raw_bytes": decoded.get("raw_bytes"),
|
||||
"payload_bytes": decoded.get("payload_bytes"),
|
||||
}
|
||||
return root, {
|
||||
"status": "ok",
|
||||
"compression": decoded.get("compression"),
|
||||
"raw_bytes": decoded.get("raw_bytes"),
|
||||
"payload_bytes": decoded.get("payload_bytes"),
|
||||
"xml_offset": start,
|
||||
"xml_bytes": len(xml_bytes),
|
||||
"xml_root": local_name(root.tag),
|
||||
}
|
||||
|
||||
|
||||
def scd_node_item(root: ET.Element, node: ET.Element, category: str) -> dict[str, Any]:
|
||||
"""Return only direct, documented XML values for one SCD item."""
|
||||
|
||||
item_name = child_text(node, "name", "dataPath", "field")
|
||||
if not item_name and not list(node):
|
||||
item_name = (node.text or "").strip()
|
||||
item: dict[str, Any] = {
|
||||
"name": item_name,
|
||||
"source": {"kind": "scd_xml", "path": node_path(root, node)},
|
||||
}
|
||||
expression = child_text(node, "expression")
|
||||
if expression:
|
||||
item["expression"] = expression
|
||||
query = child_text(node, "query")
|
||||
if query:
|
||||
item["query"] = query
|
||||
value_type_node = direct_child(node, "valueType")
|
||||
if value_type_node is None:
|
||||
value_type_node = direct_child(node, "type")
|
||||
value_type = ""
|
||||
if value_type_node is not None:
|
||||
value_type = child_text(value_type_node, "type") or (value_type_node.text or "").strip()
|
||||
if value_type:
|
||||
item["value_type"] = value_type
|
||||
if category == "datasets":
|
||||
item["type"] = node.attrib.get("{http://www.w3.org/2001/XMLSchema-instance}type") or node.attrib.get("type") or ""
|
||||
return item
|
||||
|
||||
|
||||
def inspect_scd_payload(data: bytes, *, sections: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Decode a DataCompositionSchema XML stream from SQL storage.
|
||||
|
||||
Unknown or absent XML nodes become empty lists. They are deliberately not
|
||||
synthesized from report code or form attributes.
|
||||
"""
|
||||
|
||||
requested = sections or ["parameters", "datasets", "fields", "calculated_fields", "resources", "settings", "variants", "total_fields"]
|
||||
root, container = xml_from_scd_payload(data)
|
||||
if root is None:
|
||||
return {"status": "partial", "container": container, "sections": {name: [] for name in requested}}
|
||||
schema = next((node for node in root.iter() if local_name(node.tag) == "dataCompositionSchema"), None)
|
||||
if schema is None:
|
||||
return {
|
||||
"status": "partial",
|
||||
"container": {**container, "code": "SCD_SCHEMA_NODE_NOT_FOUND"},
|
||||
"sections": {name: [] for name in requested},
|
||||
}
|
||||
node_names = {
|
||||
"parameters": {"parameter"},
|
||||
"datasets": {"dataSet"},
|
||||
"fields": {"field"},
|
||||
"calculated_fields": {"calculatedField"},
|
||||
"resources": {"resource"},
|
||||
"settings": {"settings", "Settings"},
|
||||
"variants": {"settingsVariant", "variant"},
|
||||
"total_fields": {"totalField"},
|
||||
}
|
||||
result: dict[str, list[dict[str, Any]]] = {}
|
||||
skipped_unnamed: dict[str, int] = {}
|
||||
for section in requested:
|
||||
names = node_names.get(section)
|
||||
if not names:
|
||||
result[section] = []
|
||||
continue
|
||||
raw_items = [scd_node_item(schema, node, section) for node in schema.iter() if local_name(node.tag) in names]
|
||||
result[section] = [item for item in raw_items if item.get("name")]
|
||||
if len(raw_items) != len(result[section]):
|
||||
skipped_unnamed[section] = len(raw_items) - len(result[section])
|
||||
declared = [str(item.get("name")) for item in result.get("parameters") or [] if item.get("name")]
|
||||
declared_by_normalized = {name.casefold(): name for name in declared}
|
||||
query_references: list[dict[str, Any]] = []
|
||||
referenced_normalized: set[str] = set()
|
||||
for dataset in result.get("datasets") or []:
|
||||
references: list[str] = []
|
||||
for found in QUERY_PARAMETER_RE.finditer(query_without_line_comments(str(dataset.get("query") or ""))):
|
||||
name = found.group(1)
|
||||
if name.casefold() not in {value.casefold() for value in references}:
|
||||
references.append(name)
|
||||
referenced_normalized.add(name.casefold())
|
||||
if references:
|
||||
query_references.append({"dataset": dataset.get("name"), "parameters": references})
|
||||
analysis = {
|
||||
"kind": "raw_query_parameter_token_scan",
|
||||
"declared_parameters": declared,
|
||||
"query_parameter_references": query_references,
|
||||
"referenced_not_declared_in_schema": sorted(
|
||||
{name for item in query_references for name in item["parameters"] if name.casefold() not in declared_by_normalized},
|
||||
key=str.casefold,
|
||||
),
|
||||
"declared_not_referenced_in_dataset_queries": [name for name in declared if name.casefold() not in referenced_normalized],
|
||||
}
|
||||
settings_tags = {
|
||||
"groupings": {"groupItems", "grouping"},
|
||||
"filters": {"selection", "filter"},
|
||||
"orders": {"order", "sorting"},
|
||||
"conditional_appearance": {"appearance", "conditionalAppearance"},
|
||||
}
|
||||
settings_context: dict[str, Any] = {"status": "not_present", "sections": {}}
|
||||
for context_name, tags in settings_tags.items():
|
||||
nodes = [node for node in schema.iter() if local_name(node.tag) in tags]
|
||||
if not nodes:
|
||||
continue
|
||||
records: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
tokens = []
|
||||
for child in node.iter():
|
||||
if local_name(child.tag) not in {"field", "dataPath", "left", "right", "group"} or list(child):
|
||||
continue
|
||||
value = (child.text or "").strip()
|
||||
if value and value.casefold() not in {item.casefold() for item in tokens}:
|
||||
tokens.append(value)
|
||||
if tokens:
|
||||
records.append({"path": node_path(schema, node), "tokens": tokens})
|
||||
if records:
|
||||
settings_context["status"] = "found"
|
||||
settings_context["sections"][context_name] = records
|
||||
analysis["settings_context"] = settings_context
|
||||
query_sources: list[dict[str, Any]] = []
|
||||
query_output_aliases: list[dict[str, Any]] = []
|
||||
for dataset in result.get("datasets") or []:
|
||||
query = query_without_line_comments(str(dataset.get("query") or ""))
|
||||
sources = list(dict.fromkeys(match.group(1) for match in QUERY_SOURCE_RE.finditer(query)))
|
||||
if sources:
|
||||
bindings = []
|
||||
for match in QUERY_SOURCE_BINDING_RE.finditer(query):
|
||||
source, alias = match.group(1), match.group(2)
|
||||
item = {"source": source}
|
||||
if alias:
|
||||
item["alias"] = alias
|
||||
if item not in bindings:
|
||||
bindings.append(item)
|
||||
query_sources.append({"dataset": dataset.get("name"), "sources": sources, "bindings": bindings})
|
||||
select_match = QUERY_SELECT_RE.search(query)
|
||||
if select_match:
|
||||
aliases = list(dict.fromkeys(match.group(1) for match in QUERY_ALIAS_RE.finditer(select_match.group(1))))
|
||||
if aliases:
|
||||
query_output_aliases.append({"dataset": dataset.get("name"), "aliases": aliases})
|
||||
if query_sources:
|
||||
analysis["data_source_references"] = {"kind": "raw_query_source_token_scan", "datasets": query_sources}
|
||||
direct_field_references: list[dict[str, Any]] = []
|
||||
for dataset in query_sources:
|
||||
query = query_without_line_comments(str(next((item.get("query") for item in result.get("datasets") or [] if item.get("name") == dataset.get("dataset")), "")))
|
||||
references: list[dict[str, str]] = []
|
||||
for binding in dataset.get("bindings") or []:
|
||||
alias = str(binding.get("alias") or "")
|
||||
if not alias:
|
||||
continue
|
||||
matcher = re.compile(r"\b" + re.escape(alias) + r"\.([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)(?![A-Za-z0-9_\u0400-\u04ff.])", re.IGNORECASE)
|
||||
for match in matcher.finditer(query):
|
||||
item = {"alias": alias, "field": match.group(1)}
|
||||
if item not in references:
|
||||
references.append(item)
|
||||
if references:
|
||||
direct_field_references.append({"dataset": dataset.get("dataset"), "references": references})
|
||||
if direct_field_references:
|
||||
analysis["query_direct_field_references"] = {"kind": "direct_alias_field_token_scan", "datasets": direct_field_references}
|
||||
field_names = {str(item.get("name")).casefold(): str(item.get("name")) for item in result.get("fields") or [] if item.get("name")}
|
||||
calculated_field_names = {str(item.get("name")).casefold(): str(item.get("name")) for item in result.get("calculated_fields") or [] if item.get("name")}
|
||||
declared_field_names = {**field_names, **calculated_field_names}
|
||||
total_names = [str(item.get("name")) for item in result.get("total_fields") or [] if item.get("name")]
|
||||
if total_names:
|
||||
analysis["total_field_references"] = {
|
||||
"fields": total_names,
|
||||
"missing_from_declared_fields": [name for name in total_names if name.casefold() not in declared_field_names],
|
||||
"status": "checked" if "fields" in result and "calculated_fields" in result else "field_sections_not_requested",
|
||||
}
|
||||
if query_output_aliases:
|
||||
analysis["query_output_aliases"] = {
|
||||
"kind": "select_clause_alias_scan",
|
||||
"datasets": query_output_aliases,
|
||||
"not_declared_as_scd_fields": sorted(
|
||||
{
|
||||
alias
|
||||
for dataset in query_output_aliases
|
||||
for alias in dataset["aliases"]
|
||||
if alias.casefold() not in declared_field_names
|
||||
},
|
||||
key=str.casefold,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"container": container,
|
||||
"sections": result,
|
||||
"analysis": analysis,
|
||||
"diagnostics": {"skipped_unnamed_xml_nodes": skipped_unnamed} if skipped_unnamed else {},
|
||||
}
|
||||
|
||||
|
||||
def plan_scd_scalar_patch(
|
||||
data: bytes,
|
||||
*,
|
||||
section: str,
|
||||
name: str,
|
||||
property_name: str,
|
||||
value: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a byte-preserving patch for one direct scalar SCD XML property.
|
||||
|
||||
Only query/expression properties are accepted in this first writer layer.
|
||||
The XML element span is collected by Expat from the original byte stream;
|
||||
all bytes outside the scalar content stay unchanged, including the 1C
|
||||
binary prefix/trailer. No database operation is performed here.
|
||||
"""
|
||||
|
||||
allowed = {
|
||||
"datasets": ({"dataSet"}, {"query"}),
|
||||
"calculated_fields": ({"calculatedField"}, {"expression"}),
|
||||
"resources": ({"resource"}, {"expression"}),
|
||||
}
|
||||
tags_and_properties = allowed.get(section)
|
||||
if not tags_and_properties or property_name not in tags_and_properties[1]:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"code": "SCD_PATCH_PROPERTY_UNSUPPORTED",
|
||||
"message": "Only datasets.query, calculated_fields.expression, and resources.expression are writable.",
|
||||
}
|
||||
root, container = xml_from_scd_payload(data)
|
||||
if root is None:
|
||||
return {"status": "undecodable", "container": container}
|
||||
decoded = decode_payload_lossless(data)
|
||||
payload = bytes(decoded["payload"])
|
||||
xml_start = payload.find(b"<?xml")
|
||||
if xml_start < 0:
|
||||
xml_start = payload.find(b"<SchemaFile")
|
||||
xml_end_marker = b"</SchemaFile>"
|
||||
xml_end = payload.find(xml_end_marker, xml_start)
|
||||
if xml_start < 0 or xml_end < 0:
|
||||
return {"status": "undecodable", "container": container}
|
||||
xml_end += len(xml_end_marker)
|
||||
xml = payload[xml_start:xml_end]
|
||||
target_tags = tags_and_properties[0]
|
||||
stack: list[dict[str, Any]] = []
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
def start_element(tag: str, _attrs: dict[str, str]) -> None:
|
||||
local = local_name(tag)
|
||||
position = parser.CurrentByteIndex
|
||||
end = xml.find(b">", position)
|
||||
frame: dict[str, Any] = {"tag": local, "depth": len(stack) + 1, "content_start": end + 1}
|
||||
if local in target_tags:
|
||||
frame["record"] = {"tag": local, "depth": len(stack) + 1, "properties": {}}
|
||||
if stack:
|
||||
parent_record = next((item.get("record") for item in reversed(stack) if item.get("record")), None)
|
||||
if parent_record and len(stack) + 1 == parent_record["depth"] + 1 and local in {"name", "dataPath", property_name}:
|
||||
frame["property_record"] = parent_record
|
||||
stack.append(frame)
|
||||
|
||||
def end_element(_tag: str) -> None:
|
||||
frame = stack.pop()
|
||||
end = parser.CurrentByteIndex
|
||||
property_record = frame.get("property_record")
|
||||
if property_record is not None:
|
||||
raw_text = xml[int(frame["content_start"]):end]
|
||||
if b"<" not in raw_text:
|
||||
property_record["properties"][frame["tag"]] = {
|
||||
"start": int(frame["content_start"]),
|
||||
"end": end,
|
||||
"text": html.unescape(raw_text.decode("utf-8")),
|
||||
}
|
||||
record = frame.get("record")
|
||||
if record is not None:
|
||||
identity = record["properties"].get("name") or record["properties"].get("dataPath")
|
||||
record["name"] = identity.get("text") if identity else ""
|
||||
records.append(record)
|
||||
|
||||
parser = expat.ParserCreate()
|
||||
parser.StartElementHandler = start_element
|
||||
parser.EndElementHandler = end_element
|
||||
try:
|
||||
parser.Parse(xml, True)
|
||||
except expat.ExpatError as exc:
|
||||
return {"status": "undecodable", "container": container, "code": "SCD_XML_INVALID", "message": str(exc)}
|
||||
matches = [record for record in records if str(record.get("name") or "") == name]
|
||||
if not matches:
|
||||
return {"status": "not_found", "code": "SCD_PATCH_TARGET_NOT_FOUND", "container": container}
|
||||
if len(matches) > 1:
|
||||
return {"status": "ambiguous", "code": "SCD_PATCH_TARGET_AMBIGUOUS", "container": container, "matches": len(matches)}
|
||||
property_record = (matches[0].get("properties") or {}).get(property_name)
|
||||
if not property_record:
|
||||
return {"status": "not_found", "code": "SCD_PATCH_PROPERTY_NOT_FOUND", "container": container}
|
||||
old = str(property_record["text"])
|
||||
if old == value:
|
||||
return {"status": "unchanged", "container": container, "old": old, "new": value}
|
||||
escaped = html.escape(value, quote=False).encode("utf-8")
|
||||
patched_xml = xml[: property_record["start"]] + escaped + xml[property_record["end"] :]
|
||||
patched_payload = payload[:xml_start] + patched_xml + payload[xml_end:]
|
||||
from .payload import encode_payload_lossless
|
||||
patched_data = encode_payload_lossless(decoded, payload=patched_payload)
|
||||
return {
|
||||
"status": "planned",
|
||||
"container": container,
|
||||
"old": old,
|
||||
"new": value,
|
||||
"payload": patched_data,
|
||||
"expected_sha1": hashlib.sha1(data).hexdigest(),
|
||||
"result_sha1": hashlib.sha1(patched_data).hexdigest(),
|
||||
"changed_bytes": len(patched_data) - len(data),
|
||||
}
|
||||
|
||||
|
||||
def compare_scd_semantics(active: dict[str, Any], saved: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare decoded SCD sections by semantic content, never by storage id."""
|
||||
|
||||
section_names = sorted(set((active.get("sections") or {}).keys()) | set((saved.get("sections") or {}).keys()))
|
||||
sections: dict[str, dict[str, Any]] = {}
|
||||
counts = {"added": 0, "removed": 0, "changed": 0, "unchanged": 0}
|
||||
for section in section_names:
|
||||
def index(items: Any) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for ordinal, item in enumerate(items or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = str(item.get("name") or f"#{ordinal}")
|
||||
result[key] = {key: value for key, value in item.items() if key != "source"}
|
||||
return result
|
||||
active_items, saved_items = index((active.get("sections") or {}).get(section)), index((saved.get("sections") or {}).get(section))
|
||||
added = sorted(set(saved_items) - set(active_items), key=str.casefold)
|
||||
removed = sorted(set(active_items) - set(saved_items), key=str.casefold)
|
||||
changed = sorted([name for name in set(active_items) & set(saved_items) if active_items[name] != saved_items[name]], key=str.casefold)
|
||||
unchanged = len(set(active_items) & set(saved_items)) - len(changed)
|
||||
sections[section] = {"added": added, "removed": removed, "changed": changed, "unchanged": unchanged}
|
||||
counts["added"] += len(added); counts["removed"] += len(removed); counts["changed"] += len(changed); counts["unchanged"] += unchanged
|
||||
return {"status": "unchanged" if not any(counts[key] for key in ("added", "removed", "changed")) else "changed", "sections": sections, "counts": counts}
|
||||
@@ -1,9 +1,11 @@
|
||||
Ты 1C-агент для анализа и разработки в живой конфигурации 1C через адаптер.
|
||||
Ты 1C-агент для анализа и разработки в живой конфигурации 1C через MCP-адаптер.
|
||||
Отвечай по-русски, кратко и доказательно. Не выдавай гипотезу за факт.
|
||||
|
||||
## Работа с адаптером
|
||||
|
||||
- Для любого запроса к живой базе сначала явно зафиксируй `base_id`. Адаптер не использует базу по умолчанию.
|
||||
- Вызывай адаптер только инструментом MCP `onec_request`. REST SQL-адаптер, его `/rpc`, SQL-таблицы и технические маршруты не являются инструментами агента.
|
||||
- Для чтения текущей конфигурации передавай `source_state=working`; не называй результат активированным runtime-состоянием без явного сравнения.
|
||||
- Слово «пользователь» без уточнения означает пользователя информационной базы, видимого в Конфигураторе. Начинай с `infobase.users.search`/`infobase.user.get`: `dbo.v8users` является источником платформенной идентичности, признаков аутентификации, `RolesID` и системного администратора.
|
||||
- Пользователь БСП — отдельная прикладная сущность из справочника `Пользователи`. Используй `access.users.search`/`access.user.explain` только при явном запросе про БСП, группы доступа, профили или RLS. Всегда называй такой результат «пользователь БСП».
|
||||
- Не подменяй роли пользователя Конфигуратора профилями или группами БСП. `RolesID` подтверждает назначенный платформенный набор, но точные имена его ролей должны быть получены через штатный runtime API `ПользователиИнформационнойБазы`; если runtime-канала нет, отвечай `runtime_required`, а не угадывай по БСП.
|
||||
@@ -12,12 +14,13 @@
|
||||
- Для безопасной проверки результата используй `infobase.user.password.status`: он возвращает только `empty`, `set` или `standard_authentication_disabled`, не раскрывая хеши и `Data`.
|
||||
- Новый пароль для `set` является одноразовым секретным вводом: не повторяй его в ответе, журнале, артефакте или диагностике. Отсутствие сервисной аутентификации допускается только при явно включённом адаптером тестовом режиме.
|
||||
- При сопоставлении по имени показывай два независимых слоя: `infobase_user` и `bsp_catalog_user`. Совпадение имени является корреляцией, а не доказательством тождественности или одинакового набора ролей.
|
||||
- Если в контексте уже есть точный `module_ref`, `module_id`, GUID, storage key или read selector, используй прямое чтение (`modules.read` или соответствующий read-метод) перед глобальным поиском.
|
||||
- Начинай с полного публичного имени: область расширения + `ref` объекта + имя дочернего объекта. GUID, storage key, имя SQL-файла и `module_ref` не являются входом обычного агента.
|
||||
- Если в контексте есть `read_selector.selector_token`, вызывай только указанный в нём read-метод с этим токеном; не раскрывай и не восстанавливай его внутренний маршрут.
|
||||
- Не начинай с широкого `modules.search`, если есть точная ссылка на модуль или объект.
|
||||
- `metadata.definition.find` и глобальный поиск используй для навигации, а не как единственное доказательство отсутствия кода.
|
||||
- `not_found` означает только "не найдено выбранным методом в выбранной области". Для расширений, ConfigCAS и неполных индексов это не доказывает, что объекта или строки нет.
|
||||
- `partial`, `truncated=true`, лимит сканирования или timeout делают результат недоказательным. В ответе явно помечай такой результат как неполный и меняй стратегию на более точечную.
|
||||
- Не увеличивай глобальный `scan_limit` как первый способ решения. Сначала сузь область: объект, расширение, GUID, `module_ref`, конкретный метод, шаблон или макет.
|
||||
- Не увеличивай глобальный `scan_limit` как первый способ решения. Сначала сузь область: объект, расширение, полный `ref`, имя формы/команды, конкретный метод, шаблон или макет. Не проси и не подставляй GUID либо `module_ref`.
|
||||
|
||||
## Доказательная логика
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Summarize privacy-safe adapter JSONL telemetry.
|
||||
|
||||
Run inside the REST container or copy /data/adapter-audit.jsonl from it.
|
||||
No BSL text, SQL payload, or credentials are expected in the source log.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--log", default="/data/adapter-audit.jsonl")
|
||||
parser.add_argument("--slow-ms", type=int, default=5_000)
|
||||
parser.add_argument("--limit", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
path = Path(args.log)
|
||||
rows: list[dict] = []
|
||||
malformed_rows = 0
|
||||
if not path.exists():
|
||||
print(json.dumps({"schema": "onec_adapter_audit_summary.v1", "status": "log_not_found", "log": str(path)}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed_rows += 1
|
||||
continue
|
||||
if item.get("event") == "adapter_rpc":
|
||||
rows.append(item)
|
||||
by_base = Counter(str((row.get("request") or {}).get("base_id") or "<none>") for row in rows)
|
||||
exceptions = [row for row in rows if str(row.get("status") or "") == "exception" or row.get("error") == "request_exception"]
|
||||
rejected = [row for row in rows if str(row.get("status") or "") in {"blocked", "unsupported", "invalid_argument"}]
|
||||
slow = sorted((row for row in rows if int(row.get("duration_ms") or 0) >= args.slow_ms), key=lambda row: int(row.get("duration_ms") or 0), reverse=True)
|
||||
methods = Counter(str(row.get("method") or "<none>") for row in exceptions)
|
||||
print(json.dumps({
|
||||
"schema": "onec_adapter_audit_summary.v1",
|
||||
"events": len(rows),
|
||||
"malformed_rows": malformed_rows,
|
||||
"time_range": {"from": rows[0].get("time") if rows else None, "to": rows[-1].get("time") if rows else None},
|
||||
"bases": dict(by_base),
|
||||
"adapter_exceptions": len(exceptions),
|
||||
"expected_rejections": len(rejected),
|
||||
"exception_methods": dict(methods.most_common(args.limit)),
|
||||
"slow_threshold_ms": args.slow_ms,
|
||||
"slow": [
|
||||
{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "status": row.get("status"), "error": row.get("error"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")}
|
||||
for row in slow[:args.limit]
|
||||
],
|
||||
"recent_exceptions": [
|
||||
{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "exception_type": row.get("exception_type"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")}
|
||||
for row in exceptions[-args.limit:]
|
||||
],
|
||||
"findings": [
|
||||
*([{"priority": "P1", "kind": "adapter_exception", "count": len(exceptions), "next_action": "Inspect the matching REST request_id and exception_type; reproduce only on upo_test before changing code."}] if exceptions else []),
|
||||
*([{"priority": "P2", "kind": "slow_calls", "count": len(slow), "next_action": "Inspect timings_ms for the listed methods; optimise only after a repeated pattern is confirmed."}] if slow else []),
|
||||
*([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect log rotation and container shutdown events."}] if malformed_rows else []),
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -204,7 +204,7 @@ def render_markdown(payload: dict[str, Any]) -> str:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Analyze SQL MOXCEL merge-block row/size scalar bands against XML merge rows.")
|
||||
parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011")
|
||||
parser.add_argument("--adapter-url", default="http://docker.cin.su:8011")
|
||||
parser.add_argument("--base-id", default="upo_test")
|
||||
parser.add_argument("--owner-kind", default="Document")
|
||||
parser.add_argument("--owner-name", default="АвансовыйОтчет")
|
||||
|
||||
@@ -323,7 +323,7 @@ def render_markdown(payload: dict[str, Any]) -> str:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Score SQL MOXCEL merge-block numeric slots against XML merge range fields.")
|
||||
parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011")
|
||||
parser.add_argument("--adapter-url", default="http://docker.cin.su:8011")
|
||||
parser.add_argument("--base-id", default="upo_test")
|
||||
parser.add_argument("--owner-kind", default="Document")
|
||||
parser.add_argument("--owner-name", default="АвансовыйОтчет")
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_BASE_URL = "http://docker.cin.su:8011"
|
||||
DEFAULT_BASE_ID = "upo_test"
|
||||
|
||||
# Metadata kinds that either own application data or expose values through the
|
||||
|
||||
@@ -279,7 +279,7 @@ def render_markdown(snapshot: dict[str, Any], diff: dict[str, Any] | None, previ
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Capture and diff a live 1C MOXCEL template probe snapshot.")
|
||||
parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011")
|
||||
parser.add_argument("--adapter-url", default="http://docker.cin.su:8011")
|
||||
parser.add_argument("--base-id", default="upo_test")
|
||||
parser.add_argument("--file-name", help="Explicit ConfigCAS file name. If omitted, use the newest MOXCEL payload.")
|
||||
parser.add_argument("--scan-limit", type=int, default=30)
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_REST_ADAPTER_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_REST_ADAPTER_URL = "http://docker.cin.su:8011"
|
||||
DEFAULT_MCP_URL = "http://docker.cin.su:8021"
|
||||
SAVED_STATE_TABLES = ("ConfigSave", "ConfigCASSave")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -509,11 +510,13 @@ def check_contract() -> dict[str, Any]:
|
||||
|
||||
calls: list[tuple[str, str, Any]] = []
|
||||
|
||||
def fake_http_json(method: str, path: str, body: Any | None = None, *, timeout: float | None = None) -> dict[str, Any]:
|
||||
def fake_http_json(method: str, path: str, body: Any | None = None, *, timeout: float | None = None, request_id: str | None = None) -> dict[str, Any]:
|
||||
calls.append((method, path, body))
|
||||
return {"status": "ok", "method": body.get("method") if isinstance(body, dict) else "health"}
|
||||
|
||||
original_http_json = adapter_mcp.http_json
|
||||
previous_diagnostic_mode = os.environ.get("ONEC_MCP_ALLOW_DIAGNOSTIC")
|
||||
os.environ["ONEC_MCP_ALLOW_DIAGNOSTIC"] = "true"
|
||||
adapter_mcp.http_json = fake_http_json
|
||||
try:
|
||||
calls.clear()
|
||||
@@ -562,6 +565,10 @@ def check_contract() -> dict[str, Any]:
|
||||
issues.append({"code": "mcp_rpc_body_method_mismatch", "method": method, "body": body})
|
||||
finally:
|
||||
adapter_mcp.http_json = original_http_json
|
||||
if previous_diagnostic_mode is None:
|
||||
os.environ.pop("ONEC_MCP_ALLOW_DIAGNOSTIC", None)
|
||||
else:
|
||||
os.environ["ONEC_MCP_ALLOW_DIAGNOSTIC"] = previous_diagnostic_mode
|
||||
|
||||
return {
|
||||
"schema": "onec_mcp_adapter_contract_check.v1",
|
||||
|
||||
@@ -389,7 +389,7 @@ def check_manifest(
|
||||
manifest_path: Path,
|
||||
*,
|
||||
live: bool = False,
|
||||
adapter_url: str = "http://docker-gpu.cin.su:8011",
|
||||
adapter_url: str = "http://docker.cin.su:8011",
|
||||
service_token: str = "",
|
||||
timeout: float = 90,
|
||||
base_overrides: dict[str, str] | None = None,
|
||||
@@ -453,7 +453,7 @@ def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate and optionally probe rare 1C metadata-kind fixtures.")
|
||||
parser.add_argument("--manifest", type=Path, default=Path("config/1c_metadata_kind_fixtures.json"))
|
||||
parser.add_argument("--live", action="store_true")
|
||||
parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011")
|
||||
parser.add_argument("--adapter-url", default="http://docker.cin.su:8011")
|
||||
parser.add_argument("--service-token-env", default="ONEC_ADAPTER_SERVICE_TOKEN")
|
||||
parser.add_argument("--timeout", type=float, default=90)
|
||||
parser.add_argument("--target-base", action="append", default=[], metavar="FIXTURE_ID=BASE_ID")
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_BASE_URL = "http://docker.cin.su:8011"
|
||||
DEFAULT_TABLES = ("ConfigCASSave", "ConfigSave")
|
||||
ALLOWED_TABLES = {"ConfigCASSave", "ConfigSave"}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -15,6 +16,10 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_REPORTS_ROOT = ROOT / "reports" / "1c-sql"
|
||||
SAVED_STATE_TABLES = {"ConfigSave", "ConfigCASSave"}
|
||||
SAVED_STATE_SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"}
|
||||
EXTENSION_GUID_LAYER_RE = re.compile(
|
||||
r"^extension:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def safe_path_segment(value: str) -> str:
|
||||
@@ -146,6 +151,7 @@ def validate_selector_chain(
|
||||
resolve_overrides = coverage.get("resolve_overrides") if isinstance(coverage.get("resolve_overrides"), dict) else {}
|
||||
saved_state_resolution = coverage.get("saved_state_resolution") if isinstance(coverage.get("saved_state_resolution"), dict) else {}
|
||||
composition = coverage.get("write_plan_composition") if isinstance(coverage.get("write_plan_composition"), dict) else {}
|
||||
form_composition = coverage.get("form_write_plan_composition") if isinstance(coverage.get("form_write_plan_composition"), dict) else {}
|
||||
skips = coverage.get("skips") if isinstance(coverage.get("skips"), list) else None
|
||||
summary = {
|
||||
"path": str(path),
|
||||
@@ -161,6 +167,9 @@ def validate_selector_chain(
|
||||
"write_plan_target": saved_state_resolution.get("write_plan_target"),
|
||||
"composition_status": composition.get("status"),
|
||||
"composed": composition.get("composed"),
|
||||
"form_composition_status": form_composition.get("status"),
|
||||
"form_composed": form_composition.get("composed"),
|
||||
"form_name_first": form_composition.get("name_first"),
|
||||
}
|
||||
if not report:
|
||||
return summary
|
||||
@@ -177,7 +186,7 @@ def validate_selector_chain(
|
||||
)
|
||||
if report.get("passed") is not True:
|
||||
failures.append({"code": "selector_chain_not_passed", "label": label, "path": str(path), "issues": report.get("issues")})
|
||||
for section in ("resolve_overrides", "saved_state_resolution", "write_plan_composition", "skips"):
|
||||
for section in ("resolve_overrides", "saved_state_resolution", "write_plan_composition", "form_write_plan_composition", "skips"):
|
||||
if section not in coverage:
|
||||
failures.append({"code": "selector_chain_coverage_section_missing", "label": label, "section": section, "path": str(path)})
|
||||
if resolve_overrides.get("attempted") is not True:
|
||||
@@ -207,10 +216,23 @@ def validate_selector_chain(
|
||||
})
|
||||
if composition.get("composed") is True and composition.get("status") in {None, "skipped_no_saved_state_target"}:
|
||||
failures.append({"code": "selector_chain_composed_status_unexpected", "label": label, "path": str(path), "composition": composition})
|
||||
if not isinstance(form_composition.get("attempted"), bool):
|
||||
failures.append({"code": "selector_chain_form_composition_attempted_not_boolean", "label": label, "path": str(path), "composition": form_composition})
|
||||
if not isinstance(form_composition.get("composed"), bool):
|
||||
failures.append({"code": "selector_chain_form_composed_not_boolean", "label": label, "path": str(path), "composition": form_composition})
|
||||
if form_composition.get("candidate") is True and form_composition.get("composed") is not True:
|
||||
failures.append({"code": "selector_chain_form_target_not_composed", "label": label, "path": str(path), "composition": form_composition})
|
||||
if form_composition.get("composed") is True and form_composition.get("name_first") is not True:
|
||||
failures.append({"code": "selector_chain_form_composition_not_name_first", "label": label, "path": str(path), "composition": form_composition})
|
||||
if skips is None:
|
||||
failures.append({"code": "selector_chain_skips_not_list", "label": label, "path": str(path), "skips": coverage.get("skips")})
|
||||
if require_composition and composition.get("composed") is not True:
|
||||
failures.append({"code": "selector_chain_composition_required", "label": label, "path": str(path), "composition": composition})
|
||||
if require_composition and (
|
||||
form_composition.get("composed") is not True
|
||||
or form_composition.get("name_first") is not True
|
||||
):
|
||||
failures.append({"code": "selector_chain_form_composition_required", "label": label, "path": str(path), "composition": form_composition})
|
||||
steps = report.get("steps") if isinstance(report.get("steps"), list) else []
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
@@ -379,7 +401,15 @@ def validate_write_preflight(
|
||||
failures.append({"code": "write_preflight_not_ok", "label": label, "path": str(path), "status": report.get("status")})
|
||||
if report.get("failures"):
|
||||
failures.append({"code": "write_preflight_failures_present", "label": label, "path": str(path), "failures": report.get("failures")})
|
||||
for check in ("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight"):
|
||||
for check in (
|
||||
"method_exposed",
|
||||
"effective_path_preflight",
|
||||
"concrete_saved_state_preflight",
|
||||
"name_first_extension_form_preflight",
|
||||
"conflicting_extension_selector_preflight",
|
||||
"name_first_extension_module_preflight",
|
||||
"conflicting_extension_module_selector_preflight",
|
||||
):
|
||||
if check not in checks:
|
||||
failures.append({"code": "write_preflight_check_missing", "label": label, "check": check, "path": str(path)})
|
||||
expect_check(checks, failures, label, path, "method_exposed", {"status": "ok"}, failure_code="write_preflight_check_field_unexpected")
|
||||
@@ -403,6 +433,150 @@ def validate_write_preflight(
|
||||
{"schema": "onec_metadata_write_preflight.v1", "writer": "metadata.module.write_apply"},
|
||||
failure_code="write_preflight_check_field_unexpected",
|
||||
)
|
||||
name_first_form = checks.get("name_first_extension_form_preflight") if isinstance(checks.get("name_first_extension_form_preflight"), dict) else {}
|
||||
require_name_first_form = bool(
|
||||
(report.get("requirements") or {}).get("name_first_extension_form")
|
||||
if isinstance(report.get("requirements"), dict)
|
||||
else False
|
||||
)
|
||||
if name_first_form.get("status") == "skipped_no_public_extension_form_target":
|
||||
if require_name_first_form:
|
||||
failures.append({
|
||||
"code": "write_preflight_name_first_extension_form_required",
|
||||
"label": label,
|
||||
"path": str(path),
|
||||
})
|
||||
else:
|
||||
expect_check(
|
||||
checks,
|
||||
failures,
|
||||
label,
|
||||
path,
|
||||
"name_first_extension_form_preflight",
|
||||
{
|
||||
"schema": "onec_metadata_write_preflight.v1",
|
||||
"plan_status": "planned",
|
||||
"plan_allowed": True,
|
||||
"name_first": True,
|
||||
},
|
||||
failure_code="write_preflight_check_field_unexpected",
|
||||
)
|
||||
repository_layer = str(name_first_form.get("repository_layer_id") or "")
|
||||
support_layer = str(name_first_form.get("support_layer_id") or "")
|
||||
if not EXTENSION_GUID_LAYER_RE.fullmatch(repository_layer):
|
||||
failures.append({
|
||||
"code": "write_preflight_extension_layer_unresolved",
|
||||
"label": label,
|
||||
"path": str(path),
|
||||
"repository_layer_id": repository_layer or None,
|
||||
})
|
||||
if repository_layer != support_layer:
|
||||
failures.append({
|
||||
"code": "write_preflight_extension_layer_mismatch",
|
||||
"label": label,
|
||||
"path": str(path),
|
||||
"repository_layer_id": repository_layer or None,
|
||||
"support_layer_id": support_layer or None,
|
||||
})
|
||||
conflicting_extension = checks.get("conflicting_extension_selector_preflight") if isinstance(checks.get("conflicting_extension_selector_preflight"), dict) else {}
|
||||
if conflicting_extension.get("status") == "skipped_no_public_extension_form_target":
|
||||
if require_name_first_form:
|
||||
failures.append({
|
||||
"code": "write_preflight_extension_conflict_check_required",
|
||||
"label": label,
|
||||
"path": str(path),
|
||||
})
|
||||
else:
|
||||
expect_check(
|
||||
checks,
|
||||
failures,
|
||||
label,
|
||||
path,
|
||||
"conflicting_extension_selector_preflight",
|
||||
{
|
||||
"schema": "onec_metadata_write_preflight.v1",
|
||||
"status": "blocked",
|
||||
"allowed": False,
|
||||
"resolution_status": "conflict",
|
||||
"error": "extension_selector_conflict",
|
||||
"layer_id": "extension:unresolved",
|
||||
},
|
||||
failure_code="write_preflight_check_field_unexpected",
|
||||
)
|
||||
name_first_module = checks.get("name_first_extension_module_preflight") if isinstance(checks.get("name_first_extension_module_preflight"), dict) else {}
|
||||
require_name_first_module = bool(
|
||||
(report.get("requirements") or {}).get("name_first_extension_module")
|
||||
if isinstance(report.get("requirements"), dict)
|
||||
else False
|
||||
)
|
||||
if name_first_module.get("status") == "skipped_no_public_extension_module_target":
|
||||
if require_name_first_module:
|
||||
failures.append({
|
||||
"code": "write_preflight_name_first_extension_module_required",
|
||||
"label": label,
|
||||
"path": str(path),
|
||||
})
|
||||
else:
|
||||
expect_check(
|
||||
checks,
|
||||
failures,
|
||||
label,
|
||||
path,
|
||||
"name_first_extension_module_preflight",
|
||||
{
|
||||
"schema": "onec_metadata_write_preflight.v1",
|
||||
"plan_status": "planned",
|
||||
"plan_allowed": True,
|
||||
"name_first": True,
|
||||
},
|
||||
failure_code="write_preflight_check_field_unexpected",
|
||||
)
|
||||
repository_layer = str(name_first_module.get("repository_layer_id") or "")
|
||||
support_layer = str(name_first_module.get("support_layer_id") or "")
|
||||
if not EXTENSION_GUID_LAYER_RE.fullmatch(repository_layer):
|
||||
failures.append({
|
||||
"code": "write_preflight_extension_module_layer_unresolved",
|
||||
"label": label,
|
||||
"path": str(path),
|
||||
"repository_layer_id": repository_layer or None,
|
||||
})
|
||||
if repository_layer != support_layer:
|
||||
failures.append({
|
||||
"code": "write_preflight_extension_module_layer_mismatch",
|
||||
"label": label,
|
||||
"path": str(path),
|
||||
"repository_layer_id": repository_layer or None,
|
||||
"support_layer_id": support_layer or None,
|
||||
})
|
||||
conflicting_extension_module = (
|
||||
checks.get("conflicting_extension_module_selector_preflight")
|
||||
if isinstance(checks.get("conflicting_extension_module_selector_preflight"), dict)
|
||||
else {}
|
||||
)
|
||||
if conflicting_extension_module.get("status") == "skipped_no_public_extension_module_target":
|
||||
if require_name_first_module:
|
||||
failures.append({
|
||||
"code": "write_preflight_extension_module_conflict_check_required",
|
||||
"label": label,
|
||||
"path": str(path),
|
||||
})
|
||||
else:
|
||||
expect_check(
|
||||
checks,
|
||||
failures,
|
||||
label,
|
||||
path,
|
||||
"conflicting_extension_module_selector_preflight",
|
||||
{
|
||||
"schema": "onec_metadata_write_preflight.v1",
|
||||
"status": "blocked",
|
||||
"allowed": False,
|
||||
"resolution_status": "conflict",
|
||||
"error": "extension_selector_conflict",
|
||||
"layer_id": "extension:unresolved",
|
||||
},
|
||||
failure_code="write_preflight_check_field_unexpected",
|
||||
)
|
||||
if require_mcp_initialize:
|
||||
if "mcp.initialize" not in checks:
|
||||
failures.append({"code": "write_preflight_check_missing", "label": label, "check": "mcp.initialize", "path": str(path)})
|
||||
@@ -686,7 +860,14 @@ def validate_saved_state_form(
|
||||
if status not in {"skipped_no_saved_state", "verified_and_rolled_back"}:
|
||||
failures.append({"code": "saved_state_form_status_unexpected", "path": str(path), "status": status})
|
||||
if status in {"skipped_no_saved_state", "verified_and_rolled_back"}:
|
||||
validate_saved_state_preflight(preflight, failures, "saved_state_form", path, expected_count_keys=("forms", "scanned", "limit"))
|
||||
expected_count_keys = ("forms", "limit") if status == "skipped_no_saved_state" else ("forms", "scanned", "limit")
|
||||
validate_saved_state_preflight(
|
||||
preflight,
|
||||
failures,
|
||||
"saved_state_form",
|
||||
path,
|
||||
expected_count_keys=expected_count_keys,
|
||||
)
|
||||
if status == "skipped_no_saved_state":
|
||||
if report.get("skipped") is not True:
|
||||
failures.append({"code": "saved_state_form_skip_flag_missing", "path": str(path), "skipped": report.get("skipped")})
|
||||
@@ -745,7 +926,8 @@ def validate_saved_state_module(
|
||||
write_plan = report.get("write_plan") if isinstance(report.get("write_plan"), dict) else {}
|
||||
preflight = report.get("saved_state_preflight") if isinstance(report.get("saved_state_preflight"), dict) else {}
|
||||
preflight_counts = preflight.get("counts") if isinstance(preflight.get("counts"), dict) else {}
|
||||
table = table_from_module_ref(report.get("module_ref"))
|
||||
table = report.get("table") or report.get("saved_state_table") or table_from_module_ref(report.get("module_ref"))
|
||||
skip_statuses = {"skipped_no_saved_state", "skipped_no_saved_state_candidate"}
|
||||
summary = {
|
||||
"path": str(path),
|
||||
"status": report.get("status"),
|
||||
@@ -763,19 +945,33 @@ def validate_saved_state_module(
|
||||
expect_report_identity(report, failures, label, path, expected_base_id=base_id)
|
||||
if table not in SAVED_STATE_TABLES:
|
||||
failures.append({"code": "saved_state_module_table_unexpected", "path": str(path), "module_ref": report.get("module_ref")})
|
||||
if require_write and report.get("status") == "skipped_no_saved_state":
|
||||
if require_write and report.get("status") in skip_statuses:
|
||||
failures.append({"code": "saved_state_module_write_required", "path": str(path)})
|
||||
if report.get("status") not in {"skipped_no_saved_state", "verified_and_rolled_back"}:
|
||||
if report.get("status") not in skip_statuses | {"verified_and_rolled_back"}:
|
||||
failures.append({"code": "saved_state_module_status_unexpected", "path": str(path), "status": report.get("status")})
|
||||
if report.get("status") in {"skipped_no_saved_state", "verified_and_rolled_back"}:
|
||||
validate_saved_state_preflight(preflight, failures, "saved_state_module", path, expected_count_keys=("modules", "scanned", "limit"))
|
||||
if report.get("status") == "skipped_no_saved_state":
|
||||
expected_count_keys = ("modules", "limit") if report.get("status") == "skipped_no_saved_state" else ("modules", "scanned", "limit")
|
||||
validate_saved_state_preflight(
|
||||
preflight,
|
||||
failures,
|
||||
"saved_state_module",
|
||||
path,
|
||||
expected_count_keys=expected_count_keys,
|
||||
)
|
||||
if report.get("status") in skip_statuses:
|
||||
if report.get("skipped") is not True:
|
||||
failures.append({"code": "saved_state_module_skip_flag_missing", "path": str(path), "skipped": report.get("skipped")})
|
||||
if report.get("status") == "skipped_no_saved_state_candidate":
|
||||
diagnostics = report.get("diagnostics") if isinstance(report.get("diagnostics"), dict) else {}
|
||||
if not str(diagnostics.get("message") or "").strip():
|
||||
failures.append({"code": "saved_state_module_skip_diagnostics_missing", "path": str(path)})
|
||||
if report.get("status") == "verified_and_rolled_back":
|
||||
if write_plan.get("allowed") is not True:
|
||||
failures.append({"code": "saved_state_module_write_plan_not_allowed", "path": str(path), "write_plan": write_plan})
|
||||
if write_plan.get("apply_method") != "metadata.module.write_apply":
|
||||
if write_plan.get("apply_method") not in {
|
||||
"metadata.module.write_apply",
|
||||
"form_embedded_module_handler_write_apply",
|
||||
}:
|
||||
failures.append({"code": "saved_state_module_apply_method_unexpected", "path": str(path), "write_plan": write_plan})
|
||||
if write_plan.get("target_kind") != "module":
|
||||
failures.append({"code": "saved_state_module_target_kind_unexpected", "path": str(path), "write_plan": write_plan})
|
||||
@@ -821,9 +1017,9 @@ def validate_code_write_saved_state(
|
||||
expected_transport=expected_transport,
|
||||
expected_endpoint_url=expected_endpoint_url,
|
||||
)
|
||||
if require_write and report.get("status") == "skipped_missing_target":
|
||||
if require_write and report.get("status") in {"skipped_missing_target", "skipped_write_gate"}:
|
||||
failures.append({"code": "code_write_saved_state_required", "path": str(path), "status": report.get("status")})
|
||||
if report.get("status") not in {"ok", "skipped_missing_target"}:
|
||||
if report.get("status") not in {"ok", "skipped_missing_target", "skipped_write_gate"}:
|
||||
failures.append({"code": "code_write_saved_state_status_unexpected", "path": str(path), "status": report.get("status")})
|
||||
if report.get("failures"):
|
||||
failures.append({"code": "code_write_saved_state_failures_present", "path": str(path), "failures": report.get("failures")})
|
||||
@@ -1426,6 +1622,15 @@ def write_self_test_reports(report_dir: Path, *, base_id: str, composed: bool, s
|
||||
"composed": composed,
|
||||
"from_write_plan_target": composed,
|
||||
},
|
||||
"form_write_plan_composition": {
|
||||
"attempted": composed,
|
||||
"search_attempted": composed,
|
||||
"search_status": "ok" if composed else None,
|
||||
"candidate": composed,
|
||||
"status": "planned" if composed else None,
|
||||
"composed": composed,
|
||||
"name_first": composed,
|
||||
},
|
||||
"skips": [] if composed else [{"step": "metadata.write.plan", "status": "skipped_no_saved_state_target"}],
|
||||
},
|
||||
"steps": [
|
||||
@@ -1527,6 +1732,44 @@ def write_self_test_reports(report_dir: Path, *, base_id: str, composed: bool, s
|
||||
"freshness": "live_sql_verified",
|
||||
"writer": "metadata.module.write_apply",
|
||||
},
|
||||
"name_first_extension_form_preflight": {
|
||||
"schema": "onec_metadata_write_preflight.v1",
|
||||
"status": "ready",
|
||||
"allowed": True,
|
||||
"plan_status": "planned",
|
||||
"plan_allowed": True,
|
||||
"repository_layer_id": "extension:11111111-1111-1111-1111-111111111111",
|
||||
"support_layer_id": "extension:11111111-1111-1111-1111-111111111111",
|
||||
"name_first": True,
|
||||
"extension": "test2",
|
||||
},
|
||||
"conflicting_extension_selector_preflight": {
|
||||
"schema": "onec_metadata_write_preflight.v1",
|
||||
"status": "blocked",
|
||||
"allowed": False,
|
||||
"resolution_status": "conflict",
|
||||
"error": "extension_selector_conflict",
|
||||
"layer_id": "extension:unresolved",
|
||||
},
|
||||
"name_first_extension_module_preflight": {
|
||||
"schema": "onec_metadata_write_preflight.v1",
|
||||
"status": "ready",
|
||||
"allowed": True,
|
||||
"plan_status": "planned",
|
||||
"plan_allowed": True,
|
||||
"repository_layer_id": "extension:11111111-1111-1111-1111-111111111111",
|
||||
"support_layer_id": "extension:11111111-1111-1111-1111-111111111111",
|
||||
"name_first": True,
|
||||
"extension": "test2",
|
||||
},
|
||||
"conflicting_extension_module_selector_preflight": {
|
||||
"schema": "onec_metadata_write_preflight.v1",
|
||||
"status": "blocked",
|
||||
"allowed": False,
|
||||
"resolution_status": "conflict",
|
||||
"error": "extension_selector_conflict",
|
||||
"layer_id": "extension:unresolved",
|
||||
},
|
||||
},
|
||||
"failures": [],
|
||||
}
|
||||
@@ -1906,6 +2149,40 @@ def run_self_test() -> dict[str, Any]:
|
||||
if soft["failures"]:
|
||||
failures.append({"code": "self_test_soft_unexpected_failures", "failures": soft["failures"]})
|
||||
|
||||
public_skip_dir = reports_root / "public_skip"
|
||||
write_self_test_reports(public_skip_dir, base_id="public_skip", composed=False, saved_state_written=False)
|
||||
public_form_path = public_skip_dir / "saved-state-write-routes-smoke.json"
|
||||
public_form_report = json.loads(public_form_path.read_text(encoding="utf-8"))
|
||||
public_form_report["saved_state_preflight"]["counts"].pop("scanned", None)
|
||||
write_json(public_form_path, public_form_report)
|
||||
public_module_path = public_skip_dir / "module-stream-write-smoke-script.json"
|
||||
public_module_report = json.loads(public_module_path.read_text(encoding="utf-8"))
|
||||
public_module_report.update({
|
||||
"status": "skipped_no_saved_state_candidate",
|
||||
"table": "ConfigCASSave",
|
||||
"diagnostics": {"message": "no safe saved-state BSL module stream candidates found"},
|
||||
})
|
||||
public_module_report.pop("module_ref", None)
|
||||
public_module_report.pop("saved_state_preflight", None)
|
||||
public_module_report.pop("write_plan", None)
|
||||
public_module_report.pop("metadata_write", None)
|
||||
write_json(public_module_path, public_module_report)
|
||||
public_skip = validate_base("public_skip", public_skip_dir, validator_args())
|
||||
if public_skip["failures"]:
|
||||
failures.append({"code": "self_test_public_skip_unexpected_failures", "failures": public_skip["failures"]})
|
||||
public_skip_strict = validate_base(
|
||||
"public_skip",
|
||||
public_skip_dir,
|
||||
validator_args(require_saved_state_write_smoke=True),
|
||||
)
|
||||
public_skip_strict_codes = {str(failure.get("code")) for failure in public_skip_strict["failures"]}
|
||||
if "saved_state_module_write_required" not in public_skip_strict_codes:
|
||||
failures.append({
|
||||
"code": "self_test_public_skip_strict_expected_failure_missing",
|
||||
"expected": "saved_state_module_write_required",
|
||||
"actual": sorted(public_skip_strict_codes),
|
||||
})
|
||||
|
||||
strict_skip = validate_base(
|
||||
base_id,
|
||||
report_dir,
|
||||
@@ -1917,6 +2194,7 @@ def run_self_test() -> dict[str, Any]:
|
||||
strict_codes = {str(failure.get("code")) for failure in strict_skip["failures"]}
|
||||
for expected in (
|
||||
"selector_chain_composition_required",
|
||||
"selector_chain_form_composition_required",
|
||||
"saved_state_form_write_required",
|
||||
"saved_state_module_write_required",
|
||||
):
|
||||
@@ -2339,6 +2617,8 @@ def run_self_test() -> dict[str, Any]:
|
||||
"schema": "onec_verify_reports_self_test.v1",
|
||||
"passed": not failures,
|
||||
"soft": soft,
|
||||
"public_skip": public_skip,
|
||||
"public_skip_strict_failure_codes": sorted(public_skip_strict_codes),
|
||||
"strict_skip_failure_codes": sorted(strict_codes),
|
||||
"coverage_failure_codes": sorted(coverage_codes),
|
||||
"consistency_failure_codes": sorted(consistency_codes),
|
||||
|
||||
@@ -169,8 +169,14 @@ def check_adapter_verify_wiring(scripts: list[Path], executable: str) -> list[st
|
||||
failures.append("verify_1c_adapter_deployment.ps1 must use -SavedStateTable for copy plan and saved-state smoke commands.")
|
||||
if verify_text.count("--require-write-plan-composition") < 2:
|
||||
failures.append("verify_1c_adapter_deployment.ps1 must pass --require-write-plan-composition to both REST and MCP selector-chain smoke commands.")
|
||||
if verify_text.count("--require-form-write-plan-composition") < 2:
|
||||
failures.append("verify_1c_adapter_deployment.ps1 must pass --require-form-write-plan-composition to both REST and MCP selector-chain smoke commands.")
|
||||
if "--allow-empty-saved-state" not in verify_text or "if (-not $RequireSavedStateWriteSmoke)" not in verify_text:
|
||||
failures.append("verify_1c_adapter_deployment.ps1 must allow empty saved-state only when -RequireSavedStateWriteSmoke is not set.")
|
||||
if '"skipped_no_saved_state_candidate"' not in verify_text:
|
||||
failures.append("verify_1c_adapter_deployment.ps1 must accept a missing safe module candidate in optional saved-state smoke mode.")
|
||||
if "$RequireWrite -and $report.status -in $skipStatuses" not in verify_text:
|
||||
failures.append("verify_1c_adapter_deployment.ps1 must reject all saved-state module skip statuses in strict mode.")
|
||||
if "function Get-DuplicateValues" not in verify_text or "Duplicate BaseId value(s)" not in verify_text:
|
||||
failures.append("verify_1c_adapter_deployment.ps1 must reject duplicate -BaseId values before writing reports.")
|
||||
if "function Normalize-BaseIds" not in verify_text or '-split ","' not in verify_text:
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
param(
|
||||
[string]$RestDockerHost = "ssh://docker-gpu.cin.su",
|
||||
[string]$RestDockerHost = "ssh://docker.cin.su",
|
||||
[string]$McpDockerHost = "ssh://docker.cin.su",
|
||||
[string]$RestComposePath = "core/deploy/docker-gpu/adapter-1c/compose.yaml",
|
||||
[string]$RestComposePath = "core/deploy/docker/adapter-1c/compose.yaml",
|
||||
[string]$McpComposePath = "core/deploy/docker/adapter-1c-mcp/compose.yaml",
|
||||
[string]$RestEnvFile,
|
||||
[string]$McpEnvFile,
|
||||
[string]$RestServiceName = "adapter-1c-rest",
|
||||
[string]$RestAuditServiceName = "adapter-1c-audit",
|
||||
[string]$McpServiceName = "adapter-1c-mcp",
|
||||
[string]$McpAuditServiceName = "adapter-1c-mcp-audit",
|
||||
[string[]]$BaseId,
|
||||
[string]$AdapterUrl = "http://docker-gpu.cin.su:8011",
|
||||
[string]$AdapterUrl = "http://docker.cin.su:8011",
|
||||
[string]$McpUrl = "http://docker.cin.su:8021",
|
||||
[string]$ObjectRef,
|
||||
[string]$ObjectKind,
|
||||
@@ -19,6 +23,7 @@ param(
|
||||
[switch]$SkipRest,
|
||||
[switch]$SkipMcp,
|
||||
[switch]$SkipVerify,
|
||||
[switch]$SkipDrainCheck,
|
||||
[switch]$SkipWritePlanSafetySmoke,
|
||||
[switch]$SkipWriteRollbackSafetySmoke,
|
||||
[switch]$SkipSavedStateDiffSmoke,
|
||||
@@ -50,9 +55,13 @@ function Invoke-ComposeUp {
|
||||
[string]$Label,
|
||||
[string]$DockerHost,
|
||||
[string]$ComposePath,
|
||||
[string]$EnvFile,
|
||||
[string]$ServiceName
|
||||
)
|
||||
$command = @("docker", "--host", $DockerHost, "compose", "-f", $ComposePath, "up", "-d", "--no-deps")
|
||||
if ($EnvFile) {
|
||||
$command = @("docker", "--host", $DockerHost, "compose", "--env-file", $EnvFile, "-f", $ComposePath, "up", "-d", "--no-deps")
|
||||
}
|
||||
if (-not $NoBuild) {
|
||||
$command += "--build"
|
||||
}
|
||||
@@ -60,6 +69,34 @@ function Invoke-ComposeUp {
|
||||
Invoke-CheckedCommand -Label $Label -Command $command
|
||||
}
|
||||
|
||||
function Wait-RestAdapterIdle {
|
||||
if ($SkipDrainCheck) {
|
||||
Write-Host "[skip] REST drain check was explicitly skipped"
|
||||
return
|
||||
}
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSec)
|
||||
$lastIssue = ""
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Method Get -Uri ($AdapterUrl.TrimEnd('/') + "/health") -TimeoutSec 10
|
||||
$runtime = $health.runtime
|
||||
if (-not $runtime) {
|
||||
Write-Warning "REST adapter is a legacy image without runtime drain telemetry; proceeding with this one transition deployment"
|
||||
return
|
||||
}
|
||||
if ($runtime -and $runtime.state -eq "ready" -and [int]$runtime.active_rpc_count -eq 0) {
|
||||
Write-Host "[ready] REST adapter has no active RPC calls"
|
||||
return
|
||||
}
|
||||
$lastIssue = "state=$($runtime.state) active_rpc_count=$($runtime.active_rpc_count)"
|
||||
} catch {
|
||||
$lastIssue = $_.Exception.Message
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
throw "REST adapter did not become idle before deployment: $lastIssue. Re-run later or pass -SkipDrainCheck only after confirming no write is active."
|
||||
}
|
||||
|
||||
function Ensure-RestServiceToken {
|
||||
if ($env:ONEC_ADAPTER_SERVICE_TOKEN) {
|
||||
return
|
||||
@@ -135,15 +172,27 @@ try {
|
||||
$env:ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN = "true"
|
||||
}
|
||||
Ensure-RestServiceToken
|
||||
Wait-RestAdapterIdle
|
||||
Invoke-ComposeUp `
|
||||
-Label "Deploy REST adapter" `
|
||||
-DockerHost $RestDockerHost `
|
||||
-ComposePath $RestComposePath `
|
||||
-EnvFile $RestEnvFile `
|
||||
-ServiceName $RestServiceName
|
||||
Write-ContainerSummary `
|
||||
-Label "REST adapter container" `
|
||||
-DockerHost $RestDockerHost `
|
||||
-ServiceName $RestServiceName
|
||||
Invoke-ComposeUp `
|
||||
-Label "Deploy REST audit analyzer" `
|
||||
-DockerHost $RestDockerHost `
|
||||
-ComposePath $RestComposePath `
|
||||
-EnvFile $RestEnvFile `
|
||||
-ServiceName $RestAuditServiceName
|
||||
Write-ContainerSummary `
|
||||
-Label "REST audit analyzer container" `
|
||||
-DockerHost $RestDockerHost `
|
||||
-ServiceName $RestAuditServiceName
|
||||
}
|
||||
|
||||
if (-not $SkipMcp) {
|
||||
@@ -151,11 +200,22 @@ try {
|
||||
-Label "Deploy MCP proxy" `
|
||||
-DockerHost $McpDockerHost `
|
||||
-ComposePath $McpComposePath `
|
||||
-EnvFile $McpEnvFile `
|
||||
-ServiceName $McpServiceName
|
||||
Write-ContainerSummary `
|
||||
-Label "MCP proxy container" `
|
||||
-DockerHost $McpDockerHost `
|
||||
-ServiceName $McpServiceName
|
||||
Invoke-ComposeUp `
|
||||
-Label "Deploy MCP audit analyzer" `
|
||||
-DockerHost $McpDockerHost `
|
||||
-ComposePath $McpComposePath `
|
||||
-EnvFile $McpEnvFile `
|
||||
-ServiceName $McpAuditServiceName
|
||||
Write-ContainerSummary `
|
||||
-Label "MCP audit analyzer container" `
|
||||
-DockerHost $McpDockerHost `
|
||||
-ServiceName $McpAuditServiceName
|
||||
}
|
||||
|
||||
if (-not $SkipVerify) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
param(
|
||||
[string]$DockerHost = "ssh://docker-gpu.cin.su",
|
||||
[string]$ComposeFile = "core/deploy/docker-gpu/embeddings/compose.yaml",
|
||||
[string]$EnvFile = "core/deploy/docker-gpu/embeddings/.env.example",
|
||||
[string]$BaseUrl = "http://docker-gpu.cin.su:8082",
|
||||
[string]$ExpectedModel = "qwen3-embedding-0.6b",
|
||||
[int]$WaitSeconds = 900,
|
||||
[switch]$ConfigOnly,
|
||||
[switch]$Pull,
|
||||
[switch]$Down
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ComposeFile)) {
|
||||
throw "Compose file not found: $ComposeFile"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $EnvFile)) {
|
||||
throw "Env file not found: $EnvFile"
|
||||
}
|
||||
|
||||
$composeArgs = @(
|
||||
"--host", $DockerHost,
|
||||
"compose",
|
||||
"--env-file", $EnvFile,
|
||||
"-f", $ComposeFile
|
||||
)
|
||||
|
||||
if ($ConfigOnly) {
|
||||
docker @composeArgs config
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if ($Down) {
|
||||
docker @composeArgs down
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if ($Pull) {
|
||||
docker @composeArgs pull
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
}
|
||||
|
||||
docker @composeArgs up -d
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($WaitSeconds)
|
||||
$lastError = ""
|
||||
do {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Method Get -Uri "$($BaseUrl.TrimEnd('/'))/health" -TimeoutSec 10
|
||||
if ($health.status -eq "ok") {
|
||||
$models = Invoke-RestMethod -Method Get -Uri "$($BaseUrl.TrimEnd('/'))/v1/models" -TimeoutSec 10
|
||||
$modelIds = @($models.data | ForEach-Object { $_.id })
|
||||
if ($modelIds -notcontains $ExpectedModel) {
|
||||
throw "Expected model '$ExpectedModel' is absent. Loaded: $($modelIds -join ', ')"
|
||||
}
|
||||
|
||||
$body = @{
|
||||
model = $ExpectedModel
|
||||
input = @("поиск процедуры проведения документа 1С")
|
||||
} | ConvertTo-Json -Depth 4
|
||||
$embedding = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$($BaseUrl.TrimEnd('/'))/v1/embeddings" `
|
||||
-ContentType "application/json; charset=utf-8" `
|
||||
-Body ([Text.Encoding]::UTF8.GetBytes($body)) `
|
||||
-TimeoutSec 120
|
||||
$dimensions = @($embedding.data[0].embedding).Count
|
||||
if ($dimensions -le 0) {
|
||||
throw "Embedding endpoint returned an empty vector."
|
||||
}
|
||||
Write-Host "Embedding endpoint is ready: model=$ExpectedModel dimensions=$dimensions url=$BaseUrl"
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$lastError = $_.Exception.Message
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
} while ([DateTimeOffset]::UtcNow -lt $deadline)
|
||||
|
||||
docker @composeArgs logs --tail 100
|
||||
throw "Embedding endpoint did not become ready in $WaitSeconds seconds. Last error: $lastError"
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from embed_1c_semantic_cache import DEFAULT_ADAPTER_URL, adapter_call, batched, embedding_model_label
|
||||
from rag_embedding_providers import LOCAL_HASHING_MODEL, LOCAL_HASHING_PROVIDER, embed_texts, provider_metadata
|
||||
|
||||
|
||||
def code_embedding_model_label(*, provider: str, model: str, dimensions: int) -> str:
|
||||
label = embedding_model_label(provider=provider, model=model)
|
||||
normalized_provider = str(provider or "").strip().lower().replace("_", "-")
|
||||
if normalized_provider in {"openai-compatible", "openai"} and int(dimensions or 0) > 0:
|
||||
return f"{label}@d{int(dimensions)}"
|
||||
return label
|
||||
|
||||
|
||||
def embed_pending_code_vectors(
|
||||
*,
|
||||
adapter_url: str,
|
||||
base_id: str,
|
||||
limit: int = 100,
|
||||
batch_size: int = 16,
|
||||
embedding_provider: str = LOCAL_HASHING_PROVIDER,
|
||||
embedding_model: str = LOCAL_HASHING_MODEL,
|
||||
dimensions: int = 384,
|
||||
embedding_base_url: str = "",
|
||||
embedding_api_key_env: str = "OPENAI_API_KEY",
|
||||
chunk_kinds: tuple[str, ...] | list[str] = ("routine",),
|
||||
max_text_chars: int = 4000,
|
||||
dry_run: bool = False,
|
||||
timeout_seconds: int = 180,
|
||||
) -> dict[str, Any]:
|
||||
stored_model = code_embedding_model_label(
|
||||
provider=embedding_provider,
|
||||
model=embedding_model,
|
||||
dimensions=dimensions,
|
||||
)
|
||||
pending = adapter_call(
|
||||
adapter_url,
|
||||
"metadata.code_vector.pending",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"embedding_model": stored_model,
|
||||
"limit": int(limit or 100),
|
||||
"chunk_kinds": list(dict.fromkeys(str(value).strip().lower() for value in chunk_kinds if str(value).strip())),
|
||||
"max_text_chars": int(max_text_chars),
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
if pending.get("status") != "ok":
|
||||
return {
|
||||
"schema": "onec_code_vector_embedding_worker.v1",
|
||||
"status": pending.get("status") or "error",
|
||||
"error": pending.get("error"),
|
||||
"pending": pending,
|
||||
}
|
||||
chunks = [item for item in pending.get("chunks") or [] if isinstance(item, dict)]
|
||||
upserts: list[dict[str, Any]] = []
|
||||
skipped: list[dict[str, Any]] = []
|
||||
for batch in batched(chunks, max(int(batch_size or 1), 1)):
|
||||
texts = [str(item.get("text") or "") for item in batch]
|
||||
vectors = embed_texts(
|
||||
texts,
|
||||
provider=embedding_provider,
|
||||
model=embedding_model,
|
||||
dimensions=dimensions,
|
||||
base_url=embedding_base_url,
|
||||
api_key_env=embedding_api_key_env,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
for item, vector in zip(batch, vectors):
|
||||
chunk_id = str(item.get("chunk_id") or "")
|
||||
text_sha1 = str(item.get("text_sha1") or "")
|
||||
if not chunk_id or not text_sha1 or not vector:
|
||||
skipped.append(
|
||||
{
|
||||
"chunk_id": chunk_id or None,
|
||||
"reason": "missing_chunk_id_text_sha1_or_embedding",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if dry_run:
|
||||
upserts.append(
|
||||
{
|
||||
"status": "dry_run",
|
||||
"chunk_id": chunk_id,
|
||||
"text_sha1": text_sha1,
|
||||
"dimensions": len(vector),
|
||||
}
|
||||
)
|
||||
continue
|
||||
result = adapter_call(
|
||||
adapter_url,
|
||||
"metadata.code_vector.embedding.upsert",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"chunk_id": chunk_id,
|
||||
"text_sha1": text_sha1,
|
||||
"embedding_model": stored_model,
|
||||
"embedding": vector,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
upserts.append(
|
||||
{
|
||||
"status": result.get("status"),
|
||||
"error": result.get("error"),
|
||||
"chunk_id": chunk_id,
|
||||
"text_sha1": text_sha1,
|
||||
"dimensions": result.get("dimensions") or len(vector),
|
||||
}
|
||||
)
|
||||
observed_dimensions = next(
|
||||
(
|
||||
int(item.get("dimensions") or 0)
|
||||
for item in upserts
|
||||
if int(item.get("dimensions") or 0) > 0
|
||||
),
|
||||
int(dimensions),
|
||||
)
|
||||
provider = provider_metadata(
|
||||
provider=embedding_provider,
|
||||
model=embedding_model,
|
||||
dimensions=observed_dimensions,
|
||||
base_url=embedding_base_url,
|
||||
)
|
||||
return {
|
||||
"schema": "onec_code_vector_embedding_worker.v1",
|
||||
"status": "ok",
|
||||
"base_id": base_id,
|
||||
"adapter_url": adapter_url,
|
||||
"dry_run": bool(dry_run),
|
||||
"embedding": {
|
||||
"provider": provider.get("embedding_provider"),
|
||||
"model": embedding_model,
|
||||
"stored_embedding_model": stored_model,
|
||||
"dimensions": observed_dimensions,
|
||||
"chunk_kinds": list(chunk_kinds),
|
||||
"max_text_chars": int(max_text_chars),
|
||||
},
|
||||
"counts": {
|
||||
"pending": len(chunks),
|
||||
"processed": len(upserts),
|
||||
"stored": len([item for item in upserts if item.get("status") == "ok"]),
|
||||
"conflicts": len([item for item in upserts if item.get("status") == "conflict"]),
|
||||
"skipped": len(skipped),
|
||||
"errors": len(
|
||||
[
|
||||
item
|
||||
for item in upserts
|
||||
if item.get("status") not in {"ok", "dry_run", "conflict"}
|
||||
]
|
||||
),
|
||||
},
|
||||
"upserts": upserts,
|
||||
**({"skipped": skipped} if skipped else {}),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Embed pending BSL code chunks from the 1C adapter local code index."
|
||||
)
|
||||
parser.add_argument("--adapter-url", default=DEFAULT_ADAPTER_URL)
|
||||
parser.add_argument("--base-id", required=True)
|
||||
parser.add_argument("--limit", type=int, default=100)
|
||||
parser.add_argument("--batch-size", type=int, default=16)
|
||||
parser.add_argument(
|
||||
"--embedding-provider",
|
||||
default=LOCAL_HASHING_PROVIDER,
|
||||
choices=[LOCAL_HASHING_PROVIDER, "openai-compatible"],
|
||||
)
|
||||
parser.add_argument("--embedding-model", default=LOCAL_HASHING_MODEL)
|
||||
parser.add_argument("--dimensions", type=int, default=384)
|
||||
parser.add_argument("--embedding-base-url", default="")
|
||||
parser.add_argument("--embedding-api-key-env", default="OPENAI_API_KEY")
|
||||
parser.add_argument(
|
||||
"--chunk-kind",
|
||||
action="append",
|
||||
choices=["routine", "module"],
|
||||
default=None,
|
||||
help="Chunk kind to embed; repeat to include both. Defaults to routine.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-text-chars",
|
||||
type=int,
|
||||
default=4000,
|
||||
help="Skip oversized chunks in this pass. Defaults to 4000 characters.",
|
||||
)
|
||||
parser.add_argument("--timeout-seconds", type=int, default=180)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = embed_pending_code_vectors(
|
||||
adapter_url=args.adapter_url,
|
||||
base_id=args.base_id,
|
||||
limit=args.limit,
|
||||
batch_size=args.batch_size,
|
||||
embedding_provider=args.embedding_provider,
|
||||
embedding_model=args.embedding_model,
|
||||
dimensions=args.dimensions,
|
||||
embedding_base_url=args.embedding_base_url,
|
||||
embedding_api_key_env=args.embedding_api_key_env,
|
||||
chunk_kinds=tuple(args.chunk_kind or ["routine"]),
|
||||
max_text_chars=args.max_text_chars,
|
||||
dry_run=args.dry_run,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
counts = result.get("counts") or {}
|
||||
print(
|
||||
"code vector embeddings: "
|
||||
f"pending={counts.get('pending')} processed={counts.get('processed')} "
|
||||
f"stored={counts.get('stored')} conflicts={counts.get('conflicts')} "
|
||||
f"errors={counts.get('errors')}"
|
||||
)
|
||||
return (
|
||||
0
|
||||
if result.get("status") == "ok"
|
||||
and int((result.get("counts") or {}).get("errors") or 0) == 0
|
||||
else 1
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user