Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.env
|
||||
*.sqlite
|
||||
*.db
|
||||
reports/
|
||||
data/
|
||||
@@ -0,0 +1,35 @@
|
||||
# 1C Adapter Connector example environment.
|
||||
# Do not put real passwords into repository files.
|
||||
|
||||
ONEC_ADAPTER_HOST=0.0.0.0
|
||||
ONEC_ADAPTER_PORT=8011
|
||||
# Required outside isolated local development. Generate a random secret and pass
|
||||
# the same value to MCP/agent as ONEC_ADAPTER_TOKEN.
|
||||
ONEC_ADAPTER_SERVICE_TOKEN=
|
||||
|
||||
# Required per-base configuration. Use password_env for each base.
|
||||
# Example:
|
||||
# ONEC_SQL_BASES_JSON={"upo_test":{"server":"sql-host.example.local","database":"upo_test","user":"configured_login","password_env":"ONEC_SQL_PASSWORD_UPO_TEST"}}
|
||||
ONEC_SQL_BASES_JSON=
|
||||
ONEC_SQL_BASES_JSON_FILE=
|
||||
|
||||
# Example secret referenced by ONEC_SQL_BASES_JSON password_env.
|
||||
ONEC_SQL_PASSWORD_UPO_TEST=
|
||||
|
||||
# Protected 1C runtime bridge for Configurator-user password operations.
|
||||
# Keep bridge tokens only in environment variables referenced by token_env.
|
||||
# Example (test networks may explicitly opt in to HTTP):
|
||||
# ONEC_INFOBASE_USER_ADMIN_BASES_JSON={"upo_test":{"url":"https://onec-runtime.example.local","token_env":"ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST"}}
|
||||
ONEC_INFOBASE_USER_ADMIN_BASES_JSON=
|
||||
ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE=/data/onec-infobase-user-admin.json
|
||||
ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST=
|
||||
# Test stands only: permits password mutation calls and runtime bridge requests
|
||||
# without Bearer tokens. Never enable for production or an untrusted network.
|
||||
ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=false
|
||||
|
||||
ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite
|
||||
ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups
|
||||
ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning
|
||||
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240
|
||||
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600
|
||||
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180
|
||||
@@ -0,0 +1,16 @@
|
||||
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/repository_control.py /app/repository_control.py
|
||||
COPY connector/admin /app/admin
|
||||
COPY parser /app/parser
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV ONEC_ADAPTER_HOST=0.0.0.0
|
||||
ENV ONEC_ADAPTER_PORT=8011
|
||||
ENV ONEC_SQL_BASES_JSON=
|
||||
|
||||
EXPOSE 8011
|
||||
CMD ["python", "/app/adapter_1c_server.py"]
|
||||
@@ -0,0 +1,512 @@
|
||||
# 1C Connector
|
||||
|
||||
Standalone-ready service for safe interaction with live 1C databases.
|
||||
|
||||
The connector is read-first and optimized for an operational coding loop where full XML export and EDT sync are too slow for every task.
|
||||
|
||||
Preferred live architecture:
|
||||
|
||||
- read-only SQL connector for fast diagnostics and data samples;
|
||||
- lightweight 1C agent for metadata, forms, commands, and BSL modules;
|
||||
- cached metadata/module snapshots with freshness checks;
|
||||
- change proposals as reviewable artifacts, not direct production writes.
|
||||
|
||||
The connector is responsible for:
|
||||
|
||||
- metadata reads;
|
||||
- BSL module search/read;
|
||||
- read-only query validation and execution;
|
||||
- metadata/module snapshots;
|
||||
- change proposals without direct apply.
|
||||
|
||||
Contracts:
|
||||
|
||||
- `contracts/openapi.yaml`
|
||||
- `policies/read-only-query.yaml`
|
||||
- `policies/change-workflow.yaml`
|
||||
- `policies/config-layer-write-policy.yaml`
|
||||
- `policies/sql-base-access-policy.yaml`
|
||||
- `policies/xml-decoding-reference-policy.yaml`
|
||||
|
||||
The model must use this connector instead of inventing metadata or directly changing a live database.
|
||||
|
||||
XML exports are development-time evidence only. They may be analyzed by
|
||||
repository scripts to infer and test generic SQL payload decoders, but the
|
||||
running connector is configured only with a SQL entry for `base_id`. It does
|
||||
not mount or read XML and rejects XML path arguments in runtime requests.
|
||||
|
||||
## Standalone boundary
|
||||
|
||||
This directory is the service boundary for the adapter. It is still developed
|
||||
inside the current monorepo, but it should be kept movable as an independent
|
||||
project.
|
||||
|
||||
Service-owned files:
|
||||
|
||||
- `adapter_1c_server.py`
|
||||
- `contracts/openapi.yaml`
|
||||
- `policies/*.yaml`
|
||||
- `Dockerfile`
|
||||
- `docker-compose.yml`
|
||||
- `.env.example`
|
||||
- `pyproject.toml`
|
||||
- `service.yaml`
|
||||
- sibling package `../parser`
|
||||
|
||||
Repository-owned integration files:
|
||||
|
||||
- `plugins/1c/mcp/adapter_1c_mcp.py`
|
||||
- `plugins/1c/agent/`
|
||||
- `plugins/1c/rag/`
|
||||
- `plugins/1c/training/`
|
||||
- top-level health and contract scripts under `scripts/`
|
||||
|
||||
The connector must not depend on RAG, training, or agent code. MCP and agent
|
||||
code may depend on the connector contract.
|
||||
|
||||
## Local Run
|
||||
|
||||
From `plugins/1c`:
|
||||
|
||||
```powershell
|
||||
python connector/adapter_1c_server.py
|
||||
```
|
||||
|
||||
From `plugins/1c/connector` after installing package dependencies:
|
||||
|
||||
```powershell
|
||||
python adapter_1c_server.py
|
||||
```
|
||||
|
||||
Health without a concrete base:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod http://localhost:8011/health
|
||||
```
|
||||
|
||||
Live database calls require `base_id` and SQL connection configuration.
|
||||
|
||||
## Configuration repository operations
|
||||
|
||||
Repository access is configured per `base_id`, preferably as a `repository`
|
||||
object inside the same JSON entry used by `ONEC_SQL_BASES_JSON_FILE`. The
|
||||
repository backend is never inferred from a bridge name or endpoint. Set
|
||||
`backend` explicitly to `direct` or `karman_bridge`; both backends execute the
|
||||
standard 1C Designer repository commands, while a Karman/Filebox bridge only
|
||||
relays the native opaque TCP stream.
|
||||
|
||||
See `config/1c_repository_bases.example.json` for a secret-free example.
|
||||
Passwords are resolved only from the configured environment-variable names.
|
||||
The adapter does not return them or store them in lock-session state.
|
||||
|
||||
When the adapter runs in a Linux container and Designer is installed on the
|
||||
Windows Docker host, use `runner.kind=http`. Run
|
||||
`scripts/run_1c_repository_runner.py` on Windows with its own external base
|
||||
configuration (example: `config/1c_repository_runner_bases.example.json`). The
|
||||
container sends only `base_id`, action, public object names, and commit comment;
|
||||
infobase/repository credentials remain on the Windows runner. Protect the
|
||||
runner with `ONEC_REPOSITORY_RUNNER_TOKEN` and a host firewall rule limited to
|
||||
the Docker host/container network.
|
||||
|
||||
The guarded workflow is:
|
||||
|
||||
1. `repository.status` (optionally `probe=true`);
|
||||
2. `repository.lock.plan` with public 1C object names;
|
||||
3. `repository.lock` with `allow_repository_lock=true`;
|
||||
4. pass the returned `lock_session_id` to write preflight/apply;
|
||||
5. `repository.commit.plan` and explicit `repository.commit`, or
|
||||
`repository.unlock` for only that adapter-owned session.
|
||||
|
||||
Apply operations are blocked for a repository-configured base unless an active
|
||||
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.
|
||||
|
||||
## Docker Run
|
||||
|
||||
Create a local `.env` from `.env.example`, keep real passwords outside git, and
|
||||
run:
|
||||
|
||||
```powershell
|
||||
docker compose -f plugins/1c/connector/docker-compose.yml --env-file plugins/1c/connector/.env up -d --build
|
||||
```
|
||||
|
||||
The compose build context is `plugins/1c` because the adapter imports the
|
||||
sibling `parser` package. If this service is moved to a separate repository,
|
||||
copy `plugins/1c/parser` into that repository or publish it as a package.
|
||||
|
||||
## Standalone Extraction Checklist
|
||||
|
||||
When the adapter is eventually moved out of this monorepo:
|
||||
|
||||
1. Copy `connector/` and `parser/`.
|
||||
2. Keep `contracts/openapi.yaml` versioned with releases.
|
||||
3. Keep policies with the service.
|
||||
4. Keep `service.yaml`, `pyproject.toml`, `Dockerfile`, `docker-compose.yml`,
|
||||
and `.env.example`.
|
||||
5. Move or duplicate contract checks that assert public behavior:
|
||||
`check_1c_write_plan_contract.py`,
|
||||
`check_1c_extension_action_contract.py`,
|
||||
`check_1c_module_origin_contract.py`, and
|
||||
`check_1c_code_symbol_contract.py`.
|
||||
6. Do not move RAG datasets, training configs, or agent prompts into the
|
||||
adapter service unless they become runtime dependencies.
|
||||
|
||||
## Live database access
|
||||
|
||||
### Web management
|
||||
|
||||
The runtime SQL connection list can be viewed and edited at
|
||||
`http://<adapter-host>:8011/admin/`. The screen supports adding, editing, and
|
||||
deleting entries and writes them atomically to `ONEC_SQL_BASES_JSON_FILE`
|
||||
(normally `/data/onec-sql-bases.json`). Production-style deployments should set
|
||||
`ONEC_ADAPTER_SERVICE_TOKEN`; the browser keeps it only in session storage. For
|
||||
the isolated test profile, `ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN=true`
|
||||
explicitly permits access without a token. Stored SQL passwords are never
|
||||
returned by the API in either profile.
|
||||
|
||||
When `ONEC_SQL_BASES_JSON` is set directly, web editing is disabled because the
|
||||
environment value would override the file. Move the connection map to the
|
||||
configured JSON file before using the screen.
|
||||
|
||||
The adapter is not tied to one 1C database. Requests that read database-specific
|
||||
sources must pass `base_id`; otherwise the adapter returns `base_id_required`.
|
||||
|
||||
### Mandatory SQL base access rule
|
||||
|
||||
`base_id` is the required settings key. Its entry contains the SQL server IP or
|
||||
host, SQL database name, login, and password (preferably through
|
||||
`password_env`). The adapter uses only that entry's existing credentials. It
|
||||
must never create or change SQL logins, database users, roles, or permissions.
|
||||
|
||||
Application data and the metadata structure are read-only. The only SQL write
|
||||
exception is a reviewed metadata saved-state change:
|
||||
|
||||
- base configuration metadata → `ConfigSave`;
|
||||
- extension metadata → `ConfigCASSave`.
|
||||
|
||||
The exception does not permit writes to application-data tables, `Config`, or
|
||||
`ConfigCAS`, and does not activate the saved configuration. Saved-state writes
|
||||
remain gated by explicit opt-in, SHA-1 precondition, backup, transaction, and
|
||||
readback verification. The binding policy is
|
||||
`policies/sql-base-access-policy.yaml`.
|
||||
|
||||
Configure every base explicitly. Prefer `password_env` so secrets stay outside
|
||||
repository files:
|
||||
|
||||
```json
|
||||
{
|
||||
"upo_test": {
|
||||
"server": "sql-host.example.local",
|
||||
"database": "upo_test",
|
||||
"user": "configured_login",
|
||||
"password_env": "ONEC_SQL_PASSWORD_UPO_TEST"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Set it as `ONEC_SQL_BASES_JSON` and pass the password separately as
|
||||
`ONEC_SQL_PASSWORD_UPO_TEST`, or mount the same JSON outside the repository and
|
||||
set `ONEC_SQL_BASES_JSON_FILE` to its container path. There is no implicit or
|
||||
default database connection.
|
||||
|
||||
Current live methods:
|
||||
|
||||
- `query.validate`
|
||||
- `query.run`
|
||||
- `extensions.list`
|
||||
- `schema.tables.list`
|
||||
- `storage.files.list`
|
||||
- `storage.file.get`
|
||||
- `metadata.dbnames.summary`
|
||||
- `metadata.kinds`
|
||||
- `metadata.objects.list`
|
||||
- `metadata.object.get`
|
||||
- `metadata.object.properties`
|
||||
- `metadata.object.decode`
|
||||
- `metadata.object.parts`
|
||||
- `metadata.object.modules`
|
||||
- `metadata.object.related`
|
||||
- `metadata.object.forms`
|
||||
- `metadata.object.templates`
|
||||
- `metadata.object.template.details`
|
||||
- `templates.read`
|
||||
- `templates.analyze`
|
||||
- `templates.map`
|
||||
- `metadata.route.resolve`
|
||||
- `metadata.form.decode`
|
||||
- `metadata.object.attributes`
|
||||
- `metadata.object.full`
|
||||
- `metadata.snapshot`
|
||||
- `codec.decode`
|
||||
- `codec.encode`
|
||||
- `extension.objects.find`
|
||||
- `modules.search`
|
||||
- `modules.read`
|
||||
|
||||
Metadata methods require `base_id` and read live `Params`, `Config`, and
|
||||
`ConfigCAS` storage through SQL. They do not use filesystem route indexes as a
|
||||
source of truth. High-level metadata methods return 1C-facing data by default:
|
||||
object identity, synonyms, decoded semantic sections, forms, modules, and
|
||||
counts. Physical SQL table names, `_Fld...` columns, DBNames indexes, and
|
||||
storage routes are internal diagnostics and are exposed only by low-level
|
||||
methods (`storage.*`, `schema.*`, `query.*`, `metadata.dbnames.*`) or by
|
||||
passing `include_storage=true`.
|
||||
|
||||
Object-scoped adapter methods accept the same public selector shapes:
|
||||
`ref`, `kind` + `name`, `guid`, or MCP-friendly
|
||||
`object_type`/`object_name`/`object_guid`. `ref` may use Russian or English
|
||||
qualified metadata names such as `Обработка.<Name>` or `Document.<Name>`.
|
||||
Client, MCP, and agent code must not add conditions for concrete object names;
|
||||
the adapter owns generic selector normalization.
|
||||
|
||||
Layer write policy:
|
||||
|
||||
- `Config` and `ConfigCAS` are **active-applied** and must be treated as read-only in adapter workflows.
|
||||
- `ConfigSave` and `ConfigCASSave` are **saved, not yet applied** layers and are the only writable targets for connector staging changes.
|
||||
- Base vs extension mapping:
|
||||
- base config → `ConfigSave`
|
||||
- extension config → `ConfigCASSave`
|
||||
- Production apply to active layers is out of scope for this connector and requires a separate human-controlled deployment path.
|
||||
|
||||
Agent-facing code write rule:
|
||||
|
||||
- BSL edits must use `code.write`, not low-level SQL/write helpers.
|
||||
- The agent passes 1C names (`object_type`/`object_name`/`routine_name`) or a
|
||||
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`.
|
||||
- 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.
|
||||
|
||||
`metadata.object.get` returns a live object card and decoded semantic sections
|
||||
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.
|
||||
|
||||
`metadata.object.properties` is the unified property endpoint for every 1C
|
||||
metadata kind. It selects a kind-specific SQL decoder for `Configuration`,
|
||||
`Constant`, `DocumentNumerator`, `IntegrationService`, `CommandGroup`,
|
||||
`ScheduledJob`, and `DocumentJournal`, and otherwise returns the generic live
|
||||
semantic profile. XML exports are analysis evidence only and are never a
|
||||
runtime source for this method or any other adapter method.
|
||||
|
||||
Managed form bodies in base `Config` are resolved from the public form GUID to
|
||||
the sibling `<guid>.0` SQL payload. Command-bar buttons expose public command
|
||||
names when their SQL binding points to a common command or a recognized
|
||||
platform standard command; standard reference field `-5` is exposed as a
|
||||
public `...Ref` data path. Callers never need the internal GUIDs or field codes.
|
||||
Element event GUIDs are converted to platform event names (for example
|
||||
`OnChange`, `ChoiceProcessing`, `AutoComplete`, `Selection`, and table row
|
||||
events) and linked to their BSL handlers when the routine is present.
|
||||
|
||||
`metadata.object.attributes` is the preferred method for "show object
|
||||
attributes/requisites" questions. It returns 1C metadata attribute names and
|
||||
tabular section names from the live Config payload. For tabular sections, it
|
||||
also returns decoded column names when nested column records are present.
|
||||
Attributes and columns include decoded type evidence (`date`, `boolean`,
|
||||
`string`, `number`, `reference`) and visible type parameters such as string
|
||||
length, number precision/scale, or reference type GUID. Reference type GUIDs are
|
||||
resolved back to live metadata object names and synonyms when the referenced
|
||||
type exists in the base metadata. It must be preferred over SQL table/column
|
||||
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.
|
||||
|
||||
`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
|
||||
1C-facing response. Module profiles include routine lists and lightweight BSL
|
||||
structural validation. Streams with BSL markers that are not complete modules
|
||||
are kept, but marked as `completeness: fragment_or_invalid`. Full module text is
|
||||
returned only with `include_module_text=true`. The method hides SQL/storage
|
||||
traces by default; pass `include_storage=true` only for adapter diagnostics.
|
||||
|
||||
`metadata.object.parts` returns object part roles by evidence: metadata
|
||||
payloads, form payloads, BSL stream containers, templates, and help/html
|
||||
payloads. Physical Config part keys and numeric suffixes are hidden by default
|
||||
and returned only with `include_storage=true`.
|
||||
|
||||
`metadata.object.modules` lists BSL stream modules discovered in those live
|
||||
parts. Public responses use 1C-facing names such as `Модуль объекта`; physical
|
||||
`module_id` values are returned only with `include_storage=true`.
|
||||
|
||||
`metadata.object.related` reads live related `Config` records referenced by
|
||||
known object-kind sections, such as document forms and templates. Missing
|
||||
references are returned explicitly with `source_missing`. Physical section paths
|
||||
and Config file names are hidden unless `include_storage=true`.
|
||||
|
||||
`metadata.object.forms` resolves object forms through `metadata.object.related`
|
||||
and then reads each form's live parts, including root `4` form payloads. Public
|
||||
responses show form names and part roles; physical payload keys are hidden unless
|
||||
`include_storage=true`.
|
||||
|
||||
`extension.objects.find` is the preferred first step for extension-specific
|
||||
tasks. It searches live extension metadata by `extension`, `query`, `kind`, or
|
||||
`guid`, returns object/template routes, and provides safe `read_selector`
|
||||
payloads for follow-up calls. It can recover extension manifest routes even
|
||||
when DBNames-Ext is incomplete; owner mismatches are returned as diagnostics
|
||||
instead of silently hiding the object.
|
||||
|
||||
`metadata.route.resolve` resolves ConfigCAS/DBNames routes for extension
|
||||
objects and child objects. Use it when a previous search returned a route
|
||||
handle or when the caller has a CAS file name but needs the live object route.
|
||||
|
||||
`templates.read` and `templates.analyze` read MXL/MOXCEL templates by owner
|
||||
selector, template selector, or direct ConfigCAS route. They return decoded
|
||||
template structure: dimensions, named areas with row/column ranges, text and
|
||||
parameter cells, column widths, cell text identifiers, cell parameters,
|
||||
area-to-cell coverage, area intersections, shape variants, and explicit
|
||||
capability flags. `merged_ranges` are reserved for authoritative merged-cell
|
||||
records; until the MOXCEL merge record is decoded, possible merges are exposed
|
||||
as `merged_range_candidates` with `confidence: low`.
|
||||
|
||||
Use `view=summary|structure|full`, `sections`, and `max_*` limits to keep
|
||||
responses small for agents. `templates.map` is the compact agent-facing wrapper
|
||||
over `templates.analyze`; it defaults to `view=summary` and is preferred when an
|
||||
agent needs a quick layout map instead of all decoded lists.
|
||||
|
||||
1C templates are not only tabular MXL/MOXCEL documents. The 1C template
|
||||
constructor offers these template types:
|
||||
|
||||
- `Табличный документ` - tabular document, MXL/MOXCEL. This is the current deep
|
||||
decoder focus.
|
||||
- `Текстовый документ` - plain or structured text payload.
|
||||
- `Двоичные данные` - arbitrary binary payload.
|
||||
- `Active document` - Active document payload.
|
||||
- `HTML документ` - HTML payload.
|
||||
- `Географическая схема` - geographic schema.
|
||||
- `Графическая схема` - graphical schema.
|
||||
- `Схема компоновки данных` - data composition schema.
|
||||
- `Макет оформления компоновки данных` - data composition appearance template.
|
||||
- `Внешняя компонента` - external component payload.
|
||||
|
||||
Always identify the template type before applying a decoder. Current
|
||||
`templates.*` decoding is evidence-first for tabular documents; non-tabular
|
||||
templates should be surfaced with type, raw route, payload markers, preview, and
|
||||
explicit capability gaps until dedicated decoders are implemented.
|
||||
|
||||
For MOXCEL reverse engineering, request `sections=moxel_records,diagnostics`
|
||||
and optionally `max_moxel_records`. The response includes parser-level record
|
||||
head counts, grouped head samples with `tree_position`, and coordinate-like
|
||||
samples. Use `top_level_records` with a larger `max_moxel_records` to inspect
|
||||
ordered MOXCEL sections around a specific tree position. These diagnostics are
|
||||
not authoritative merged-cell records. For a narrow ordered window, pass
|
||||
`moxel_record_start` and `moxel_record_end`, for example `431..460`.
|
||||
Use `moxel_record_heads` to keep only selected top-level record head codes,
|
||||
for example `1049761,1413047`. Add `moxel_record_context` to include neighbor
|
||||
records around matched top-level records; context records are marked with
|
||||
`match: false`. `top_level_record_summary` summarizes the returned record
|
||||
window with position range, head counts, match count, and compact numeric-field
|
||||
variation by head. Its `field_hints` are low-confidence labels such as
|
||||
`flag_like`, `small_enum_like`, or `coordinate_or_offset_like`; use them as
|
||||
navigation hints, not as authoritative MOXCEL decoding. `numeric_field_matrix`
|
||||
then shows those hinted/varying field values per `tree_position` without
|
||||
returning every numeric item again, and `field_runs` compresses adjacent equal
|
||||
values in that matrix. `field_transitions` lists the switch points between
|
||||
those runs. `cell_style_candidates` exposes inline MOXCEL text cells with
|
||||
nearby scalar style evidence and following metadata nodes; treat it as a
|
||||
controlled-diff aid until border/font/alignment semantics are decoded.
|
||||
`top_level_shapes`
|
||||
groups top-level records by structural shape
|
||||
(`head`, list length, numeric/string counts) and includes sample positions.
|
||||
`top_level_shape_candidates` ranks rare/long/numeric-heavy shapes as
|
||||
low-confidence hints for manual layout/merge investigation. Each candidate can
|
||||
include `rank` and `suggested_windows` with a ready `request_hint` for the next
|
||||
focused `templates.map` call. Pass `moxel_candidate_rank` to focus
|
||||
`top_level_records` on that 1-based candidate rank without copying the request
|
||||
hint manually. Use `moxel_candidate_window_index` to select a later suggested
|
||||
window from the same candidate when the structural shape appears more than
|
||||
once. Use `moxel_candidate_reasons`, for example
|
||||
`coordinate_like_prefix,long_record`, to return only candidates containing all
|
||||
requested reason codes. Use `moxel_candidate_min_score` to keep only candidates
|
||||
above a heuristic score threshold. `top_level_candidate_summary` reports score
|
||||
and reason distributions plus the count returned after filters. Use
|
||||
`moxel_candidate_heads` to filter the candidate list by head code; use
|
||||
`moxel_record_heads` when filtering actual top-level records in a focused
|
||||
window. Use `moxel_candidate_start`/`moxel_candidate_end` to filter candidates
|
||||
by their top-level positions; use `moxel_record_start`/`moxel_record_end` when
|
||||
filtering returned records.
|
||||
|
||||
`metadata.form.decode` decodes one form payload into an evidence-first profile:
|
||||
event handlers, form items, attributes, commands, auxiliary table/command-bar
|
||||
records, and the embedded form module summary. Form records include stable
|
||||
paths back into the decoded tree for names, ids, localized titles, handler
|
||||
names, and known platform event ids. Counts include both returned and total
|
||||
record numbers so truncated responses are explicit. The profile also links form
|
||||
events and form commands to module routines, links command buttons to commands
|
||||
by GUID evidence, and marks handlers as `resolved` or `missing`.
|
||||
|
||||
`modules.search` searches live BSL text and returns snippets by default.
|
||||
Physical module ids and payload coordinates are hidden unless
|
||||
`include_storage=true`. Every public match includes a `read_selector` with
|
||||
`method: "modules.read"` and either an object selector or an opaque
|
||||
`module_ref`; agents should pass that selector to the next read call instead of
|
||||
requesting storage details. When `resolve_owners=true`, results also include
|
||||
`counts.owner_resolved`, `counts.owner_unresolved`, and
|
||||
`diagnostics.owner_resolution` so incomplete owner recovery is explicit.
|
||||
`modules.read` reads by object selector (`ref`, `guid`, `kind` + `name`,
|
||||
`object_type`/`object_name`/`object_guid`, or `kind` + 1-based object
|
||||
`ordinal`) and optional 1-based `module_ordinal`; it also accepts `module_ref`
|
||||
from a prior search result. The response hides source and payload metadata
|
||||
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.
|
||||
|
||||
`metadata.definition.find` accepts public object references such as
|
||||
`Обработка.<Name>` or `Document.<Name>` in `query` and the common object
|
||||
selector aliases for scoped lookup. A single metadata object match is promoted
|
||||
to the top-level `object` field and the response includes `related_selectors`
|
||||
for the next public calls (`metadata.object.get`,
|
||||
`metadata.object.full`, `metadata.object.modules`, `metadata.object.form.details`,
|
||||
`code.search`, `modules.search`, and similar selectors allowed by the object's
|
||||
capabilities).
|
||||
|
||||
`metadata.adapter.audit` reports recognized metadata kinds, public kind counts,
|
||||
missing supported kinds when `include_missing=true`, and unmapped DBNames roles
|
||||
when `include_unmapped=true`.
|
||||
|
||||
`codec.decode` and `codec.encode` are low-level lossless helpers. A no-op encode
|
||||
from a live source keeps the original bytes exactly; modified text/tree payloads
|
||||
are encoded back using the original compression and text encoding envelope.
|
||||
|
||||
`changes.propose` reads one live storage payload, checks an optional
|
||||
`expected_sha1`, applies `edits` to decoded brace-tree paths in memory, and
|
||||
returns the re-encoded payload metadata for review. It never writes to SQL.
|
||||
Each edit has `path`, `value`, optional `node_type` (`auto`, `atom`, `string`),
|
||||
and optional `expected_old`. For stream payloads, an edit can use
|
||||
`stream_index` with either full `text` replacement or `replace: {old, new}`,
|
||||
plus optional `expected_contains`; stream headers are rebuilt with updated
|
||||
byte lengths before the payload is encoded back. Diagnostic `source.module_id`
|
||||
values returned by `metadata.object.modules` with `include_storage=true` or
|
||||
accepted by `modules.read` can be used directly; when the module id includes
|
||||
`#stream:<index>`, stream edits inherit that index unless an edit specifies its
|
||||
own `stream_index`. The response
|
||||
includes `validation`, produced by re-decoding the encoded proposal in memory.
|
||||
For BSL stream edits, validation also runs lightweight structural checks for
|
||||
routine, region, and preprocessor-block balance. Stream edits can also target
|
||||
a whole BSL routine with `routine: {operation, name, text}` where operation is
|
||||
`replace`, `append`, or `upsert`. Routine edits accept
|
||||
`expected_old_contains` and `expected_old_sha1` as live preconditions against
|
||||
the current routine text; failed preconditions reject the proposal before any
|
||||
encoded review artifact is returned.
|
||||
|
||||
`storage.*` methods read 1C storage rows directly from SQL tables
|
||||
`Params`, `Config`, `ConfigSave`, `ConfigCAS`, and `ConfigCASSave`. They are
|
||||
diagnostic building blocks for the live metadata decoder; they do not create or
|
||||
read filesystem indexes.
|
||||
|
||||
## Cache policy
|
||||
|
||||
The source of truth is the live database. A filesystem cache may be added only
|
||||
as a derived acceleration layer for expensive decoded metadata/module payloads,
|
||||
not for current table data. Cache entries must carry `base_id`, source
|
||||
fingerprint, generation time, TTL, and `fresh/stale` status. If freshness cannot
|
||||
be proven, the adapter must re-read live SQL or return an explicit stale-cache
|
||||
error.
|
||||
|
||||
Operational runbook: `docs/runbooks/1c-operational-coding.md`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = { bases: [], requests: [] };
|
||||
|
||||
function headers(json = false) {
|
||||
const value = {};
|
||||
if (json) value["Content-Type"] = "application/json";
|
||||
return value;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(path, { ...options, headers: { ...headers(Boolean(options.body)), ...(options.headers || {}) } });
|
||||
let data = {};
|
||||
try { data = await response.json(); } catch (_) { /* empty response */ }
|
||||
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function notice(message = "", error = false) {
|
||||
$("notice").hidden = !message;
|
||||
$("notice").textContent = message;
|
||||
$("notice").className = `notice${error ? " error" : ""}`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
$("bases").replaceChildren(...state.bases.map((base) => {
|
||||
const row = document.createElement("tr");
|
||||
const cells = [base.base_id, base.server, base.database, base.user];
|
||||
cells.forEach((value) => { const td = document.createElement("td"); td.textContent = value; row.append(td); });
|
||||
const password = document.createElement("td");
|
||||
password.innerHTML = `<span class="badge ${base.has_password ? "" : "missing"}">${base.password_env ? "ENV · " + escapeHtml(base.password_env) : base.has_password ? "Сохранён" : "Не задан"}</span>`;
|
||||
row.append(password);
|
||||
const actions = document.createElement("td"); actions.className = "actions";
|
||||
const edit = document.createElement("button"); edit.className = "text-button"; edit.textContent = "Изменить"; edit.onclick = () => openEditor(base);
|
||||
const remove = document.createElement("button"); remove.className = "text-button danger"; remove.textContent = "Удалить"; remove.onclick = () => deleteBase(base);
|
||||
actions.append(edit, remove); row.append(actions); return row;
|
||||
}));
|
||||
$("empty").hidden = state.bases.length > 0;
|
||||
$("count").textContent = `${state.bases.length} ${state.bases.length === 1 ? "подключение" : "подключений"}`;
|
||||
}
|
||||
|
||||
function renderRequests() {
|
||||
$("requests").replaceChildren(...state.requests.map((item) => {
|
||||
const row = document.createElement("tr");
|
||||
const values = [item.request_id, item.base_id, item.status, (item.objects || []).join(", "), item.created_at ? new Date(item.created_at * 1000).toLocaleString("ru-RU") : "—"];
|
||||
values.forEach((value, index) => { const td = document.createElement("td"); td.textContent = value || "—"; if (index === 2) td.className = `request-status ${item.status || ""}`; if (index === 3) td.className = "request-objects"; row.append(td); });
|
||||
return row;
|
||||
}));
|
||||
$("requestsEmpty").hidden = state.requests.length > 0;
|
||||
$("requestCount").textContent = `${state.requests.length} заявок`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const div = document.createElement("div"); div.textContent = value; return div.innerHTML;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
notice();
|
||||
const [data, repository] = await Promise.all([request("/admin/api/bases"), request("/admin/api/repository/requests")]);
|
||||
state.bases = data.bases || [];
|
||||
state.requests = repository.requests || [];
|
||||
$("statusDot").classList.add("online"); $("connectionText").textContent = "Адаптер подключён";
|
||||
render();
|
||||
renderRequests();
|
||||
} catch (error) {
|
||||
$("statusDot").classList.remove("online"); $("connectionText").textContent = "Требуется подключение";
|
||||
notice(error.message === "bearer_token_required" ? "Введите Bearer-токен адаптера." : error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(base = null) {
|
||||
$("editorTitle").textContent = base ? "Редактировать базу" : "Новая база";
|
||||
$("originalId").value = base?.base_id || "";
|
||||
$("baseId").value = base?.base_id || ""; $("server").value = base?.server || "";
|
||||
$("database").value = base?.database || ""; $("user").value = base?.user || "";
|
||||
$("password").value = ""; $("passwordEnv").value = base?.password_env || "";
|
||||
$("repositoryEnabled").checked = Boolean(base?.repository);
|
||||
$("repositoryBackend").value = base?.repository?.backend || "direct";
|
||||
$("repositoryLayer").value = base?.repository?.layer || "base";
|
||||
$("repositoryLockMode").value = base?.repository?.lock_mode || "automatic";
|
||||
$("repositoryBridgeId").value = base?.repository?.bridge_id || "";
|
||||
$("repositoryUser").value = base?.repository?.repository_user || "";
|
||||
$("passwordHint").textContent = base?.has_password ? "Пароль уже задан. Оставьте пустым, чтобы сохранить текущий." : "Задайте пароль или переменную окружения.";
|
||||
$("editor").showModal(); setTimeout(() => $("baseId").focus(), 30);
|
||||
}
|
||||
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
if (!$("baseForm").reportValidity()) return;
|
||||
const original = $("originalId").value;
|
||||
const payload = { base_id: $("baseId").value.trim(), server: $("server").value.trim(), database: $("database").value.trim(), user: $("user").value.trim(), password: $("password").value, password_env: $("passwordEnv").value.trim(), repository: $("repositoryEnabled").checked ? { backend: $("repositoryBackend").value, layer: $("repositoryLayer").value, lock_mode: $("repositoryLockMode").value, bridge_id: $("repositoryBridgeId").value.trim(), repository_user: $("repositoryUser").value.trim() } : { enabled: false } };
|
||||
$("saveButton").disabled = true;
|
||||
try {
|
||||
await request(original ? `/admin/api/bases/${encodeURIComponent(original)}` : "/admin/api/bases", { method: original ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||||
$("editor").close(); await load(); notice(original ? "Подключение обновлено." : "Подключение добавлено.");
|
||||
} catch (error) { notice(error.message, true); } finally { $("saveButton").disabled = false; }
|
||||
}
|
||||
|
||||
async function deleteBase(base) {
|
||||
if (!confirm(`Удалить подключение «${base.base_id}»?`)) return;
|
||||
try { await request(`/admin/api/bases/${encodeURIComponent(base.base_id)}`, { method: "DELETE" }); await load(); notice("Подключение удалено."); }
|
||||
catch (error) { notice(error.message, true); }
|
||||
}
|
||||
|
||||
$("refreshButton").onclick = load; $("addButton").onclick = () => openEditor(); $("baseForm").onsubmit = save;
|
||||
load();
|
||||
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>1С Adapter · SQL-базы</title>
|
||||
<link rel="stylesheet" href="/admin/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div><span class="mark">1C</span><strong>Adapter Control</strong></div>
|
||||
<div class="connection"><span id="statusDot" class="dot"></span><span id="connectionText">Не подключено</span></div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="intro">
|
||||
<div><p class="eyebrow">Подключения</p><h1>SQL-базы 1С</h1><p>Управление адресами и учётными данными адаптера.</p></div>
|
||||
<button id="addButton" class="primary">+ Добавить базу</button>
|
||||
</section>
|
||||
<section class="panel table-panel">
|
||||
<div class="panel-head"><div><h2>Настроенные базы</h2><span id="count">0 подключений</span></div><button id="refreshButton" class="icon" title="Обновить" aria-label="Обновить">↻</button></div>
|
||||
<div id="notice" class="notice" hidden></div>
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr><th>Base ID</th><th>SQL Server</th><th>База данных</th><th>Логин</th><th>Пароль</th><th></th></tr></thead><tbody id="bases"></tbody></table>
|
||||
<div id="empty" class="empty">Подключения ещё не настроены.</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel table-panel requests-panel">
|
||||
<div class="panel-head"><div><h2>Заявки на захват</h2><span id="requestCount">0 заявок</span></div></div>
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr><th>Заявка</th><th>База</th><th>Статус</th><th>Объекты</th><th>Создана</th></tr></thead><tbody id="requests"></tbody></table>
|
||||
<div id="requestsEmpty" class="empty">Заявок пока нет.</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<dialog id="editor">
|
||||
<form id="baseForm" method="dialog">
|
||||
<div class="dialog-head"><div><p class="eyebrow">SQL-подключение</p><h2 id="editorTitle">Новая база</h2></div><button value="cancel" class="close" aria-label="Закрыть">×</button></div>
|
||||
<input id="originalId" type="hidden">
|
||||
<div class="grid">
|
||||
<label>Base ID<input id="baseId" required pattern="[A-Za-z0-9_.-]+" placeholder="upo_test"></label>
|
||||
<label>SQL Server / IP<input id="server" required placeholder="192.168.1.10"></label>
|
||||
<label>Имя базы SQL<input id="database" required placeholder="upo_test"></label>
|
||||
<label>SQL-логин<input id="user" required autocomplete="username" placeholder="onec_reader"></label>
|
||||
<label class="wide">Пароль<input id="password" type="password" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"><small id="passwordHint">Пароль сохраняется в защищённом runtime-файле и никогда не отображается.</small></label>
|
||||
<label class="wide">Или переменная окружения<input id="passwordEnv" placeholder="ONEC_SQL_PASSWORD_UPO_TEST"><small>Если заполнено, имеет приоритет над введённым паролем.</small></label>
|
||||
<label class="wide"><input id="repositoryEnabled" type="checkbox"> Конфигурация подключена к хранилищу</label>
|
||||
<label>Доступ к хранилищу<select id="repositoryBackend"><option value="direct">Прямой</option><option value="karman_bridge">Через Карман</option></select></label>
|
||||
<label>Слой<select id="repositoryLayer"><option value="base">Основная конфигурация</option><option value="extension">Расширение</option></select></label>
|
||||
<label>Захват объектов<select id="repositoryLockMode"><option value="manual">Вручную пользователем (SQL-only)</option><option value="automatic" disabled>Через внешнюю 1С (следующая версия)</option></select></label>
|
||||
<label>ID моста (из настройки)<input id="repositoryBridgeId" placeholder="Необязательно"></label>
|
||||
<label>Пользователь хранилища<input id="repositoryUser" autocomplete="off" placeholder="Например, adm"></label>
|
||||
</div>
|
||||
<div class="dialog-actions"><button value="cancel" class="secondary">Отмена</button><button id="saveButton" value="default" class="primary">Сохранить</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<script src="/admin/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
:root{--ink:#17211b;--muted:#68736c;--line:#d7ddd8;--paper:#f3f4f0;--surface:#fff;--accent:#b8dc2e;--accent-dark:#27380c;--danger:#b33a2e;--shadow:0 18px 50px rgba(30,42,34,.11);font-family:"Segoe UI Variable","Aptos",sans-serif;color:var(--ink);background:var(--paper)}*{box-sizing:border-box}body{margin:0}.topbar{height:58px;padding:0 max(24px,calc((100vw - 1180px)/2));display:flex;align-items:center;justify-content:space-between;background:#18211c;color:#f7faf6}.topbar>div{display:flex;align-items:center;gap:10px}.mark{display:grid;place-items:center;width:30px;height:30px;background:var(--accent);color:#17210b;font-weight:900;border-radius:7px}.connection{font-size:13px;color:#bdc6bf}.dot{width:8px;height:8px;border-radius:50%;background:#78827b}.dot.online{background:var(--accent);box-shadow:0 0 0 4px rgba(184,220,46,.12)}main{max-width:1180px;margin:auto;padding:42px 24px 70px}.intro{display:flex;align-items:end;justify-content:space-between;margin-bottom:26px}.eyebrow{margin:0 0 6px;text-transform:uppercase;letter-spacing:.13em;font-size:11px;font-weight:800;color:#71804f}.intro h1{font-family:Georgia,serif;font-size:43px;line-height:1;margin:0}.intro p:not(.eyebrow){color:var(--muted);margin:12px 0 0}.panel{background:var(--surface);border:1px solid var(--line);box-shadow:0 2px 0 rgba(23,33,27,.03)}.auth{display:grid;grid-template-columns:minmax(260px,1fr) auto;gap:12px;align-items:end;padding:18px;margin-bottom:18px}.auth small{grid-column:1/-1}.auth label,.grid label{display:grid;gap:7px;font-size:12px;font-weight:700}.auth input,.grid input{width:100%;border:1px solid #bfc8c1;background:#fbfcfa;border-radius:5px;padding:11px 12px;font:inherit;color:var(--ink);outline:none}.auth input:focus,.grid input:focus{border-color:#6f861c;box-shadow:0 0 0 3px rgba(184,220,46,.18)}button{font:inherit;cursor:pointer}.primary,.secondary,.icon,.close{border:0;border-radius:5px;min-height:40px;padding:0 16px;font-weight:750}.primary{background:var(--accent);color:var(--accent-dark)}.primary:hover{filter:brightness(.94)}.secondary{background:#edf0ec;color:#344038}.table-panel{overflow:hidden}.panel-head{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}.panel-head h2{font-size:17px;margin:0 0 3px}.panel-head span{font-size:12px;color:var(--muted)}.icon,.close{font-size:22px;background:transparent;padding:0 10px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:820px}th,td{text-align:left;padding:14px 18px;border-bottom:1px solid #e5e9e6;font-size:13px}th{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#728078;background:#fafbf9}td:first-child{font-family:Consolas,monospace;font-weight:700}.actions{display:flex;justify-content:flex-end;gap:6px}.text-button{border:0;background:transparent;padding:5px;color:#516058}.text-button:hover{color:#17211b}.text-button.danger:hover{color:var(--danger)}.badge{display:inline-flex;align-items:center;gap:6px;color:#3c4a40}.badge:before{content:"";width:7px;height:7px;border-radius:50%;background:var(--accent)}.badge.missing:before{background:#d2a342}.empty{text-align:center;padding:55px;color:var(--muted)}.notice{margin:14px 18px 0;padding:11px 13px;border-left:3px solid #d2a342;background:#fff8df;font-size:13px}.notice.error{border-color:var(--danger);background:#fff0ee;color:#78271f}dialog{border:0;border-radius:8px;padding:0;width:min(660px,calc(100vw - 30px));box-shadow:var(--shadow)}dialog::backdrop{background:rgba(15,23,18,.58);backdrop-filter:blur(2px)}dialog form{padding:24px}.dialog-head{display:flex;justify-content:space-between}.dialog-head h2{font-family:Georgia,serif;font-size:28px;margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:17px;margin:25px 0}.grid .wide{grid-column:1/-1}.grid small,.auth small{color:var(--muted);font-weight:400}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;border-top:1px solid var(--line);padding-top:18px}@media(max-width:700px){main{padding:28px 14px}.intro{align-items:start;gap:20px}.intro h1{font-size:34px}.auth{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.grid .wide{grid-column:auto}.topbar{padding:0 14px}.connection span:last-child{display:none}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
onec-adapter:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: connector/Dockerfile
|
||||
image: onec-adapter-connector:0.1.0
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${ONEC_ADAPTER_PORT:-8011}:8011"
|
||||
volumes:
|
||||
- onec-adapter-data:/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import json, urllib.request; print(json.load(urllib.request.urlopen('http://127.0.0.1:8011/health', timeout=5)).get('status'))"
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
onec-adapter-data:
|
||||
@@ -0,0 +1,50 @@
|
||||
id: 1c-change-workflow-policy
|
||||
status: active
|
||||
default_mode: propose-only
|
||||
rules:
|
||||
- "Application data is read-only; no workflow may insert, update, or delete rows in application tables."
|
||||
- "SQL identities and permissions are out of scope and must never be created or changed by this connector."
|
||||
- "The model must not directly apply changes to a live 1C database."
|
||||
- "Write operations to 1C configuration data must target only ConfigSave (base config) and ConfigCASSave (extension config) as the saved layer."
|
||||
- "Do not write to Config or ConfigCAS from connector workflows; these are active-applied layers and read-only in the adapter path."
|
||||
- "Before any write proposal, resolve user-facing targets to full 1C canonical paths or concrete saved-state references."
|
||||
- "Before any write proposal, read origin/layer evidence for the effective target."
|
||||
- "Before any apply method, require metadata.write.plan allowed=true for the same target and intent."
|
||||
- "When the base repository is configured, require a verified adapter-owned repository lock session before any saved-state apply."
|
||||
- "Repository backend, endpoint, bridge identity, runtime, and credentials must come from the selected base runtime settings; never infer them from hard-coded names."
|
||||
- "Repository commit requires an explicit approval flag and a non-empty version comment."
|
||||
- "Concrete saved-state references must be compatible with the selected target kind; do not use form_guid for module writes or module_ref for form writes."
|
||||
- "Do not treat a local BSL symbol path as a metadata path until it is resolved inside the current code context."
|
||||
- "Do not write effective module or form text directly; route through a write plan with layer provenance."
|
||||
- "After modifying saved layers, require explicit compare and human approval before any production apply step."
|
||||
- "The model may generate a change proposal, patch, or review checklist."
|
||||
- "Human approval is required before apply."
|
||||
- "Production changes require backup, test run, and rollback plan."
|
||||
stages:
|
||||
- propose_change
|
||||
- static_review
|
||||
- run_tests
|
||||
- expert_review
|
||||
- manual_approve
|
||||
- apply_change
|
||||
- verify
|
||||
- rollback_if_needed
|
||||
required_for_approval:
|
||||
- risk_summary
|
||||
- affected_objects
|
||||
- canonical_paths
|
||||
- layer_provenance
|
||||
- references_found
|
||||
- test_plan
|
||||
- rollback_plan
|
||||
denied_without_approval:
|
||||
- modify_configuration
|
||||
- update_database
|
||||
- run_data_processor
|
||||
- delete_objects
|
||||
- change_roles_or_permissions
|
||||
- write_active_configuration
|
||||
- write_ambiguous_target
|
||||
- write_without_origin_evidence
|
||||
- write_when_plan_blocked
|
||||
- write_concrete_reference_kind_mismatch
|
||||
@@ -0,0 +1,36 @@
|
||||
id: 1c-config-layer-write-policy
|
||||
status: active
|
||||
default_mode: deny
|
||||
summary: "Writes to 1C configuration storage are read-first, save-layer-only."
|
||||
rules:
|
||||
- "This exception permits metadata saved-state payloads only; it never permits application-data writes."
|
||||
- "Active-applied layers are read-only in adapter workflows: Config and ConfigCAS."
|
||||
- "Saved, not yet applied layers are the only writable targets for configuration edits: ConfigSave and ConfigCASSave."
|
||||
- "Base configuration changes map to ConfigSave; extension configuration changes map to ConfigCASSave."
|
||||
- "Comparisons of pending changes must be run as ConfigSave↔Config and ConfigCASSave↔ConfigCAS before proposing production apply."
|
||||
- "Any claim of applied state must be backed by live reads from Config/ConfigCAS only after explicit apply workflow."
|
||||
- "Agent-facing write intents must resolve to a full 1C canonical path or concrete saved-state reference before planning."
|
||||
- "Effective views are read targets only; write plans must identify base, extension, generated extension source, or saved-state ownership."
|
||||
- "Concrete references must match the planned target kind: module targets may use module_ref, module_id, or module file_name; form targets may use form file_name or form_guid."
|
||||
- "If metadata.write.plan returns allowed=false, metadata.write must not call lower-level apply methods."
|
||||
- "Extension code changes must preserve the operation type: insert_before, insert_after, replace, or replace_with_control."
|
||||
denied_actions:
|
||||
- "write_to_Config"
|
||||
- "write_to_ConfigCAS"
|
||||
- "auto_apply_to_active_state"
|
||||
- "direct_sql_apply_to_live_config"
|
||||
- "write_effective_view_directly"
|
||||
- "write_ambiguous_short_name"
|
||||
- "write_plan_blocked_apply"
|
||||
- "write_concrete_reference_kind_mismatch"
|
||||
allowed_actions:
|
||||
- "propose_save_layer_change"
|
||||
- "plan_full_path_change"
|
||||
- "read_Config"
|
||||
- "read_ConfigSave"
|
||||
- "read_ConfigCAS"
|
||||
- "read_ConfigCASSave"
|
||||
- "compare_saved_state"
|
||||
notes:
|
||||
- "Use this policy together with change-workflow to avoid mixing saved and active layers."
|
||||
- "If a path requires production writes, treat it as out-of-band and human-controlled only."
|
||||
@@ -0,0 +1,69 @@
|
||||
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."
|
||||
|
||||
scope:
|
||||
default_base_id: upo_test
|
||||
allowed_base_class: disposable_test
|
||||
forbidden_base_class: [production, unclassified]
|
||||
platform_mutation_authority:
|
||||
application_data: 1c_enterprise_client
|
||||
metadata_working_state: 1c_designer
|
||||
adapter_role: sql_observer_and_decoder
|
||||
|
||||
credentials:
|
||||
persistence: forbidden_in_repository
|
||||
accepted_sources: [process_environment, operating_system_credential_store, interactive_session]
|
||||
rules:
|
||||
- "Do not put 1C user passwords, SQL passwords, tokens, or connection strings containing secrets in project files, reports, fixtures, or command examples."
|
||||
- "Redact credentials from process reports and captured command lines."
|
||||
|
||||
experiment:
|
||||
isolation: one_intended_change_per_run
|
||||
required_phases:
|
||||
- identify_public_1c_target
|
||||
- capture_sql_before
|
||||
- change_through_1c
|
||||
- save_in_1c
|
||||
- capture_sql_after
|
||||
- diff_sql
|
||||
- decode_semantic_rule
|
||||
- verify_with_second_value_or_object
|
||||
- rollback_through_1c
|
||||
- verify_rollback_in_sql
|
||||
target_selectors: [public_ref, kind_and_name, form_and_element_name, record_ref]
|
||||
forbidden_selectors_for_callers: [sql_number, physical_table, internal_guid_only]
|
||||
|
||||
sql_observation:
|
||||
adapter_access: read_only
|
||||
allowed: [SELECT, metadata_schema_inspection, ConfigSave_read, ConfigCASSave_read, application_table_read]
|
||||
forbidden:
|
||||
- direct_application_data_write
|
||||
- direct_Config_write
|
||||
- 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."
|
||||
|
||||
metadata_layers:
|
||||
designer_save:
|
||||
observe: [ConfigSave, ConfigCASSave]
|
||||
apply_configuration: false
|
||||
applied_configuration:
|
||||
observe: [Config, ConfigCAS, physical_schema]
|
||||
gate: explicit_experiment_requirement
|
||||
extensions:
|
||||
rule: "Capture the base and every extension as separate layers and record load order and ownership."
|
||||
|
||||
xml:
|
||||
role: offline_schema_reference_only
|
||||
runtime_source: forbidden
|
||||
rule: "XML may name the intended property and validate a learned rule, but live before/after evidence must come from SQL."
|
||||
|
||||
promotion_gates:
|
||||
- "The SQL diff is isolated from pre-existing Designer and configuration-check noise."
|
||||
- "A stable public 1C property or value name is resolved without requiring callers to know GUIDs or SQL numbers."
|
||||
- "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."
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
id: 1c-readonly-query-policy
|
||||
status: active
|
||||
default_mode: deny
|
||||
allowed:
|
||||
- select
|
||||
limits:
|
||||
max_rows: 1000
|
||||
default_rows: 100
|
||||
timeout_seconds: 30
|
||||
max_timeout_seconds: 120
|
||||
deny_patterns:
|
||||
- "(?i)\\bВЫБРАТЬ\\s+РАЗРЕШЕННЫЕ\\b.*\\bПОМЕСТИТЬ\\b"
|
||||
- "(?i)\\bПОМЕСТИТЬ\\b"
|
||||
- "(?i)\\bУНИЧТОЖИТЬ\\b"
|
||||
- "(?i)\\bОБНОВИТЬ\\b"
|
||||
- "(?i)\\bВСТАВИТЬ\\b"
|
||||
- "(?i)\\bУДАЛИТЬ\\b"
|
||||
- "(?i)\\bALTER\\b"
|
||||
- "(?i)\\bDROP\\b"
|
||||
- "(?i)\\bUPDATE\\b"
|
||||
- "(?i)\\bINSERT\\b"
|
||||
- "(?i)\\bDELETE\\b"
|
||||
- "(?i)\\bCREATE\\s+(LOGIN|USER|ROLE)\\b"
|
||||
- "(?i)\\bALTER\\s+(LOGIN|USER|ROLE)\\b"
|
||||
- "(?i)\\bDROP\\s+(LOGIN|USER|ROLE)\\b"
|
||||
- "(?i)\\b(GRANT|DENY|REVOKE)\\b"
|
||||
masking:
|
||||
enabled: true
|
||||
fields:
|
||||
- "(?i).*пароль.*"
|
||||
- "(?i).*телефон.*"
|
||||
- "(?i).*email.*"
|
||||
- "(?i).*почта.*"
|
||||
- "(?i).*паспорт.*"
|
||||
- "(?i).*инн.*"
|
||||
audit:
|
||||
log_queries: true
|
||||
log_params: false
|
||||
log_result_rows: false
|
||||
notes: "Read-only query runner policy. Connection identity comes only from the explicit base_id settings. Validate before execution and apply row limits/masking."
|
||||
@@ -0,0 +1,65 @@
|
||||
id: 1c-sql-base-access-policy
|
||||
status: active
|
||||
default_mode: deny
|
||||
summary: "Every 1C base uses only its explicitly configured SQL connection; data is read-only and metadata writes are saved-state-only."
|
||||
|
||||
base_settings:
|
||||
selector: base_id
|
||||
source:
|
||||
- ONEC_SQL_BASES_JSON
|
||||
- ONEC_SQL_BASES_JSON_FILE
|
||||
required_fields:
|
||||
- server
|
||||
- database
|
||||
- user
|
||||
secret_fields_one_of:
|
||||
- password
|
||||
- password_env
|
||||
rules:
|
||||
- "Every live request must contain an explicit base_id."
|
||||
- "Resolve server, database, user, and password only from the settings entry for that base_id."
|
||||
- "Do not substitute another base, infer a SQL database name, or use shared/default SQL credentials."
|
||||
- "Do not persist connection passwords in repository or project files."
|
||||
|
||||
read_scope:
|
||||
application_data: read_only
|
||||
metadata_structure: read_only
|
||||
configuration_tables:
|
||||
Config: read_only
|
||||
ConfigCAS: read_only
|
||||
ConfigSave: read_only_except_saved_state_metadata_write
|
||||
ConfigCASSave: read_only_except_saved_state_metadata_write
|
||||
|
||||
write_scope:
|
||||
allowed:
|
||||
base_metadata_saved_state: ConfigSave
|
||||
extension_metadata_saved_state: ConfigCASSave
|
||||
forbidden:
|
||||
- application_data_tables
|
||||
- Config
|
||||
- ConfigCAS
|
||||
- SQL_system_tables
|
||||
- SQL_security_objects
|
||||
constraints:
|
||||
- "The payload must be a metadata saved-state change, never application data."
|
||||
- "The target table must be exactly ConfigSave or ConfigCASSave."
|
||||
- "Require explicit saved-state write opt-in, expected SHA-1, backup, transaction, and readback verification."
|
||||
- "A saved-state write must not activate or apply the configuration."
|
||||
|
||||
sql_identity_management:
|
||||
mode: forbidden
|
||||
forbidden_actions:
|
||||
- CREATE_LOGIN
|
||||
- ALTER_LOGIN
|
||||
- DROP_LOGIN
|
||||
- CREATE_USER
|
||||
- ALTER_USER
|
||||
- DROP_USER
|
||||
- CREATE_ROLE
|
||||
- ALTER_ROLE
|
||||
- DROP_ROLE
|
||||
- GRANT
|
||||
- DENY
|
||||
- REVOKE
|
||||
rule: "Use the login and password already stored in the selected base settings; never create or modify adapter-owned SQL identities or permissions."
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
id: 1c-xml-decoding-reference-policy
|
||||
status: active
|
||||
summary: "XML exports are offline decoding evidence only; the running adapter is SQL-only."
|
||||
|
||||
offline_analysis:
|
||||
allowed: true
|
||||
purposes:
|
||||
- discover_metadata_kinds
|
||||
- enumerate_declared_properties
|
||||
- correlate_object_guids
|
||||
- infer_binary_config_paths
|
||||
- build_decoder_tests_and_fixtures
|
||||
layer_rule: "Base configuration and every extension must be analyzed as separate layers."
|
||||
output_rule: "Promote only generic, evidence-backed format rules and tests into the adapter; do not promote configuration-specific XML values as live answers."
|
||||
|
||||
runtime:
|
||||
source: sql_only
|
||||
configured_by: base_id
|
||||
settings:
|
||||
- server
|
||||
- database
|
||||
- user
|
||||
- password_or_password_env
|
||||
xml_mount_required: false
|
||||
xml_environment_variables_allowed: false
|
||||
rejected_payload_arguments:
|
||||
- xml_path
|
||||
- xml_root
|
||||
- meta_xml_path
|
||||
- form_xml_path
|
||||
- configuration_xml
|
||||
- configuration_xml_path
|
||||
- config_dump_info
|
||||
- config_dump_info_path
|
||||
rules:
|
||||
- "Runtime metadata and data answers must be derived from the selected base_id SQL connection."
|
||||
- "Runtime must not read Configuration.xml, ConfigDumpInfo.xml, form XML, extension XML, or an XML-derived object-value cache."
|
||||
- "XML-derived decoder rules must remain generic and must be verified against live SQL bytes."
|
||||
- "An XML export may differ from live extensions and therefore cannot establish the current runtime extension state."
|
||||
|
||||
writes:
|
||||
rule: "This policy does not broaden SQL write scope. Only the saved-state exceptions in sql-base-access-policy.yaml apply."
|
||||
allowed_tables:
|
||||
- ConfigSave
|
||||
- ConfigCASSave
|
||||
@@ -0,0 +1,21 @@
|
||||
[project]
|
||||
name = "onec-adapter-connector"
|
||||
version = "0.1.0"
|
||||
description = "Read-first 1C adapter connector for metadata, BSL modules, safe write planning, and saved-state staging."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pymssql==2.3.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"PyYAML",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
onec-adapter = "adapter_1c_server:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["../../../tests/1c"]
|
||||
pythonpath = ["..", ".", "../parser"]
|
||||
@@ -0,0 +1,722 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
METHOD_STATUS = "repository.status"
|
||||
METHOD_LOCK_PLAN = "repository.lock.plan"
|
||||
METHOD_LOCK_REQUEST = "repository.lock.request"
|
||||
METHOD_LOCK_REQUEST_STATUS = "repository.lock.request.status"
|
||||
METHOD_LOCK_REQUEST_CANCEL = "repository.lock.request.cancel"
|
||||
METHOD_LOCK = "repository.lock"
|
||||
METHOD_CONFIRM = "repository.lock.confirm"
|
||||
METHOD_VERIFY = "repository.lock.verify"
|
||||
METHOD_CLOSE = "repository.lock.close"
|
||||
METHOD_UNLOCK = "repository.unlock"
|
||||
METHOD_COMMIT_PLAN = "repository.commit.plan"
|
||||
METHOD_COMMIT = "repository.commit"
|
||||
METHODS = {METHOD_STATUS, METHOD_LOCK_PLAN, METHOD_LOCK_REQUEST, METHOD_LOCK_REQUEST_STATUS, METHOD_LOCK_REQUEST_CANCEL, METHOD_LOCK, METHOD_CONFIRM, METHOD_VERIFY, METHOD_CLOSE, METHOD_UNLOCK, METHOD_COMMIT_PLAN, METHOD_COMMIT}
|
||||
SUPPORTED_BACKENDS = {"direct", "karman_bridge"}
|
||||
SUPPORTED_LOCK_MODES = {"automatic", "manual"}
|
||||
_BASE_LOCKS: dict[str, threading.Lock] = {}
|
||||
_BASE_LOCKS_GUARD = threading.Lock()
|
||||
_STATE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def external_1c_enabled() -> bool:
|
||||
return str(os.environ.get("ONEC_ADAPTER_ENABLE_EXTERNAL_1C") or "").strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _base_lock(base_id: str, layer: str) -> threading.Lock:
|
||||
key = f"{base_id}:{layer}"
|
||||
with _BASE_LOCKS_GUARD:
|
||||
return _BASE_LOCKS.setdefault(key, threading.Lock())
|
||||
|
||||
|
||||
def _load_json_map(env_name: str, file_env_name: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
raw = os.environ.get(env_name)
|
||||
path = os.environ.get(file_env_name)
|
||||
if not raw and path:
|
||||
try:
|
||||
raw = Path(path).read_text(encoding="utf-8-sig")
|
||||
except Exception as exc:
|
||||
return None, {"status": "invalid_config", "message": f"Cannot read {file_env_name}: {exc}"}
|
||||
if not raw:
|
||||
return None, {
|
||||
"status": "not_configured",
|
||||
"message": f"Set {env_name} or {file_env_name} with an explicit entry for this base_id.",
|
||||
}
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, {"status": "invalid_config", "message": f"{env_name} is not valid JSON: {exc}"}
|
||||
if not isinstance(value, dict):
|
||||
return None, {"status": "invalid_config", "message": f"{env_name} must be an object keyed by base_id."}
|
||||
return value, None
|
||||
|
||||
|
||||
def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
values, error = _load_json_map("ONEC_SQL_BASES_JSON", "ONEC_SQL_BASES_JSON_FILE")
|
||||
base_item = values.get(base_id) if values and isinstance(values.get(base_id), dict) else None
|
||||
item = base_item.get("repository") if isinstance(base_item, dict) else None
|
||||
if item is None:
|
||||
values, repository_error = _load_json_map("ONEC_REPOSITORY_BASES_JSON", "ONEC_REPOSITORY_BASES_JSON_FILE")
|
||||
if repository_error and error:
|
||||
return None, repository_error
|
||||
item = values.get(base_id) if values else None
|
||||
if item is None:
|
||||
return None, {"status": "not_configured", "message": f"No repository configuration for base_id '{base_id}'."}
|
||||
if not isinstance(item, dict):
|
||||
return None, {"status": "invalid_config", "message": f"Repository configuration for '{base_id}' must be an object."}
|
||||
configured = dict(item)
|
||||
configured["backend"] = str(configured.get("backend") or "direct").strip().casefold()
|
||||
configured["layer"] = str(configured.get("layer") or "base").strip().casefold()
|
||||
configured["lock_mode"] = str(configured.get("lock_mode") or "automatic").strip().casefold()
|
||||
if configured["backend"] not in SUPPORTED_BACKENDS:
|
||||
return None, {"status": "invalid_config", "message": "repository backend must be direct or karman_bridge."}
|
||||
if configured["lock_mode"] not in SUPPORTED_LOCK_MODES:
|
||||
return None, {"status": "invalid_config", "message": "repository lock_mode must be automatic or manual."}
|
||||
runner = configured.get("runner") if isinstance(configured.get("runner"), dict) else {}
|
||||
configured["runner"] = runner
|
||||
runner_kind = str(runner.get("kind") or "local").strip().casefold()
|
||||
if runner_kind not in {"local", "http"}:
|
||||
return None, {"status": "invalid_config", "message": "repository runner.kind must be local or http."}
|
||||
runner["kind"] = runner_kind
|
||||
required = ("endpoint", "designer_path") if runner_kind == "local" and configured["lock_mode"] == "automatic" else ()
|
||||
for key in required:
|
||||
if not str(configured.get(key) or "").strip():
|
||||
return None, {"status": "invalid_config", "message": f"Repository configuration requires {key}."}
|
||||
if runner_kind == "http" and configured["lock_mode"] == "automatic" and not str(runner.get("url") or "").strip():
|
||||
return None, {"status": "invalid_config", "message": "repository runner.url is required for runner.kind=http."}
|
||||
infobase = configured.get("infobase")
|
||||
if runner_kind == "local" and configured["lock_mode"] == "automatic" and (not isinstance(infobase, dict) or sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) != 1):
|
||||
return None, {"status": "invalid_config", "message": "infobase must contain exactly one of file, server, or name for runner.kind=local."}
|
||||
return configured, None
|
||||
|
||||
|
||||
def _secret(config: dict[str, Any], field: str) -> str:
|
||||
env_name = str(config.get(f"{field}_env") or "").strip()
|
||||
return os.environ.get(env_name, "") if env_name else ""
|
||||
|
||||
|
||||
def _public_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"backend": config.get("backend"),
|
||||
"layer": config.get("layer"),
|
||||
"lock_mode": config.get("lock_mode"),
|
||||
"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"),
|
||||
"bridge_id": config.get("bridge_id") if config.get("backend") == "karman_bridge" else None,
|
||||
"runtime_version": config.get("runtime_version"),
|
||||
"runner_kind": (config.get("runner") or {}).get("kind"),
|
||||
"runner_url": (config.get("runner") or {}).get("url"),
|
||||
"runner_token_env": (config.get("runner") or {}).get("token_env"),
|
||||
"repository_user": str(config.get("repository_user") or ""),
|
||||
"repository_password_env": str(config.get("repository_password_env") or ""),
|
||||
"repository_user_configured": bool(str(config.get("repository_user") or "").strip()),
|
||||
"repository_password_configured": bool(_secret(config, "repository_password")),
|
||||
"infobase_user": str(config.get("infobase_user") or ""),
|
||||
"infobase_password_env": str(config.get("infobase_password_env") or ""),
|
||||
"infobase_user_configured": bool(str(config.get("infobase_user") or "").strip()),
|
||||
"infobase_password_configured": bool(_secret(config, "infobase_password")),
|
||||
}
|
||||
|
||||
|
||||
def _infobase_args(config: dict[str, Any]) -> list[str]:
|
||||
infobase = config["infobase"]
|
||||
if infobase.get("file"):
|
||||
args = ["/F", str(infobase["file"])]
|
||||
elif infobase.get("server"):
|
||||
args = ["/S", str(infobase["server"])]
|
||||
else:
|
||||
args = ["/IBName", str(infobase["name"])]
|
||||
user = str(config.get("infobase_user") or "").strip()
|
||||
if user:
|
||||
args += ["/N", user]
|
||||
password = _secret(config, "infobase_password")
|
||||
if password:
|
||||
args += ["/P", password]
|
||||
return args
|
||||
|
||||
|
||||
def _repository_args(config: dict[str, Any]) -> list[str]:
|
||||
args = ["/ConfigurationRepositoryF", str(config["endpoint"])]
|
||||
user = str(config.get("repository_user") or "").strip()
|
||||
if user:
|
||||
args += ["/ConfigurationRepositoryN", user]
|
||||
password = _secret(config, "repository_password")
|
||||
if password:
|
||||
args += ["/ConfigurationRepositoryP", password]
|
||||
extension = str(config.get("extension") or "").strip()
|
||||
if extension:
|
||||
args += ["-Extension", extension]
|
||||
return args
|
||||
|
||||
|
||||
def _safe_excerpt(value: str, config: dict[str, Any], limit: int = 4000) -> str:
|
||||
safe = value
|
||||
for secret in (_secret(config, "repository_password"), _secret(config, "infobase_password")):
|
||||
if secret:
|
||||
safe = safe.replace(secret, "[REDACTED]")
|
||||
return safe[-limit:]
|
||||
|
||||
|
||||
def _run_designer(config: dict[str, Any], operation: list[str], timeout_seconds: int) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
with tempfile.TemporaryDirectory(prefix="onec-repository-") as directory:
|
||||
log_path = Path(directory) / "designer.log"
|
||||
args = [str(config["designer_path"]), "DESIGNER"]
|
||||
args += _infobase_args(config)
|
||||
args += ["/DisableStartupMessages", "/DisableStartupDialogs", "/Out", str(log_path)]
|
||||
args += _repository_args(config)
|
||||
args += operation
|
||||
try:
|
||||
completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout_seconds, check=False)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return {
|
||||
"status": "timeout",
|
||||
"exit_code": None,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
"output": _safe_excerpt(str(exc.stdout or "") + str(exc.stderr or ""), config),
|
||||
}
|
||||
except OSError as exc:
|
||||
return {"status": "runner_error", "exit_code": None, "message": str(exc), "duration_ms": round((time.monotonic() - started) * 1000)}
|
||||
log = ""
|
||||
try:
|
||||
log = log_path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
except OSError:
|
||||
pass
|
||||
output = "\n".join(part for part in (completed.stdout, completed.stderr, log) if part)
|
||||
return {
|
||||
"status": "ok" if completed.returncode == 0 else "failed",
|
||||
"exit_code": completed.returncode,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
"output": _safe_excerpt(output, config),
|
||||
}
|
||||
|
||||
|
||||
def _execute_repository(
|
||||
base_id: str,
|
||||
config: dict[str, Any],
|
||||
action: str,
|
||||
timeout_seconds: int,
|
||||
*,
|
||||
objects: list[str] | None = None,
|
||||
comment: str = "",
|
||||
keep_locked: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not external_1c_enabled():
|
||||
return {
|
||||
"status": "external_1c_disabled",
|
||||
"message": "This adapter version is SQL-only. Use repository.lock.plan and the manual confirmation workflow.",
|
||||
}
|
||||
runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"}
|
||||
if runner.get("kind") == "http":
|
||||
url = str(runner.get("url") or "").rstrip("/") + "/repository/execute"
|
||||
body = json.dumps(
|
||||
{"base_id": base_id, "action": action, "objects": objects or [], "comment": comment, "keep_locked": keep_locked},
|
||||
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"Repository runner returned HTTP {exc.code}."}
|
||||
return result if isinstance(result, dict) else {"status": "runner_error", "message": f"Repository 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": "Repository runner returned a non-object response."}
|
||||
if action == "report":
|
||||
with tempfile.TemporaryDirectory(prefix="onec-repository-report-") as directory:
|
||||
report = Path(directory) / "report.txt"
|
||||
return _run_designer(config, ["/ConfigurationRepositoryReport", str(report), "-NBegin", "-1", "-ReportFormat", "txt"], timeout_seconds)
|
||||
with tempfile.TemporaryDirectory(prefix="onec-repository-objects-") as directory:
|
||||
objects_path = Path(directory) / "objects.txt"
|
||||
objects_path.write_text("\n".join(objects or []) + "\n", encoding="utf-8")
|
||||
if action == "lock":
|
||||
operation = ["/ConfigurationRepositoryLock", "-Objects", str(objects_path)]
|
||||
elif action == "unlock":
|
||||
operation = ["/ConfigurationRepositoryUnlock", "-Objects", str(objects_path)]
|
||||
elif action == "commit":
|
||||
operation = ["/ConfigurationRepositoryCommit", "-Objects", str(objects_path), "-Comment", comment]
|
||||
if keep_locked:
|
||||
operation.append("-KeepLocked")
|
||||
else:
|
||||
return {"status": "runner_error", "message": f"Unsupported repository action: {action}"}
|
||||
return _run_designer(config, operation, timeout_seconds)
|
||||
|
||||
|
||||
_CHILD_MARKERS = re.compile(
|
||||
r"\.(?:Реквизит|Attribute|ТабличнаяЧасть|TabularSection|Измерение|Dimension|Ресурс|Resource)\.",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def development_object(ref: str) -> str:
|
||||
value = ref.strip().strip(".")
|
||||
match = _CHILD_MARKERS.search(value)
|
||||
if match:
|
||||
return value[: match.start()]
|
||||
for marker in (".МодульОбъекта", ".ObjectModule", ".МодульМенеджера", ".ManagerModule"):
|
||||
if value.casefold().endswith(marker.casefold()):
|
||||
return value[: -len(marker)]
|
||||
return value
|
||||
|
||||
|
||||
def _requested_objects(payload: dict[str, Any]) -> tuple[list[str] | None, dict[str, Any] | None]:
|
||||
raw = payload.get("objects")
|
||||
if raw is None:
|
||||
raw = [payload.get("object") or payload.get("ref") or payload.get("path")]
|
||||
if not isinstance(raw, list) or not raw:
|
||||
return None, {"status": "invalid_argument", "argument": "objects", "message": "Pass object/ref/path or a non-empty objects array."}
|
||||
values: list[str] = []
|
||||
for index, item in enumerate(raw):
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
return None, {"status": "invalid_argument", "argument": f"objects[{index}]", "message": "Repository object must be a non-empty public 1C reference."}
|
||||
resolved = development_object(item)
|
||||
if resolved not in values:
|
||||
values.append(resolved)
|
||||
return values, None
|
||||
|
||||
|
||||
def lock_plan(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
objects, error = _requested_objects(payload)
|
||||
if error:
|
||||
return {"schema": "onec_repository_lock_plan.v1", "method": METHOD_LOCK_PLAN, **error}
|
||||
operation = str(payload.get("operation") or "modify").strip().casefold()
|
||||
warnings: list[dict[str, str]] = []
|
||||
if operation in {"add", "delete", "rename"}:
|
||||
warnings.append({"code": "parent_scope_requires_confirmation", "message": "Structural operations can require the parent/root and referenced objects; confirm the complete set before lock/apply."})
|
||||
result = {
|
||||
"schema": "onec_repository_lock_plan.v1",
|
||||
"method": METHOD_LOCK_PLAN,
|
||||
"status": "ready" if not warnings else "needs_confirmation",
|
||||
"operation": operation,
|
||||
"requested_objects": [str(x) for x in (payload.get("objects") or [payload.get("object") or payload.get("ref") or payload.get("path")])],
|
||||
"lock_objects": objects,
|
||||
"warnings": warnings,
|
||||
}
|
||||
base_id = str(payload.get("base_id") or "").strip()
|
||||
if base_id:
|
||||
config, config_error = repository_config(base_id)
|
||||
if config_error:
|
||||
result["repository_problem"] = config_error
|
||||
elif config.get("lock_mode") == "manual":
|
||||
result["workflow"] = "manual"
|
||||
result["next_method"] = METHOD_CONFIRM
|
||||
result["user_action"] = {
|
||||
"action": "lock_in_configurator",
|
||||
"base_id": base_id,
|
||||
"objects": objects,
|
||||
"message": "Захватите перечисленные объекты в Конфигураторе, затем явно подтвердите тот же список через repository.lock.confirm.",
|
||||
}
|
||||
else:
|
||||
result["workflow"] = "automatic"
|
||||
result["next_method"] = METHOD_LOCK
|
||||
return result
|
||||
|
||||
|
||||
def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
base_id = str(payload.get("base_id") or "").strip()
|
||||
config, error = repository_config(base_id)
|
||||
if error:
|
||||
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, **error}
|
||||
plan = lock_plan(payload)
|
||||
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
|
||||
plan["status"] = "ready"
|
||||
if plan.get("status") != "ready":
|
||||
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "status": "blocked", "plan": plan}
|
||||
request_id = "rreq-" + uuid.uuid4().hex
|
||||
with _STATE_LOCK:
|
||||
state = _read_state()
|
||||
state.setdefault("requests", {})[request_id] = {
|
||||
"base_id": base_id,
|
||||
"layer": str(config.get("layer") or "base"),
|
||||
"backend": config.get("backend"),
|
||||
"operation": plan.get("operation"),
|
||||
"objects": plan["lock_objects"],
|
||||
"created_at": time.time(),
|
||||
"status": "pending_user_lock",
|
||||
"execution": "manual",
|
||||
}
|
||||
_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, "status": "pending_user_lock", "request_id": request_id,
|
||||
"objects": plan["lock_objects"], "automatically_locked": False,
|
||||
"user_action": "Захватите перечисленные объекты в Конфигураторе и подтвердите заявку через repository.lock.confirm.",
|
||||
"next_method": METHOD_CONFIRM,
|
||||
}
|
||||
|
||||
|
||||
def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request_id = str(payload.get("request_id") or "").strip()
|
||||
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}
|
||||
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
|
||||
|
||||
|
||||
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()
|
||||
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}
|
||||
if request.get("status") != "pending_user_lock":
|
||||
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "blocked", "error": "lock_request_not_pending", "request_id": request_id}
|
||||
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:
|
||||
return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json")
|
||||
|
||||
|
||||
def _read_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": []}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
state = {"sessions": {}, "requests": {}, "audit": []}
|
||||
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))
|
||||
for request in (state.get("requests") or {}).values():
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
def _audit(state: dict[str, Any], event: str, **details: Any) -> None:
|
||||
rows = state.setdefault("audit", [])
|
||||
rows.append({"event": event, "time": time.time(), **details})
|
||||
if len(rows) > 5000:
|
||||
del rows[:-5000]
|
||||
|
||||
|
||||
def admin_state(base_id: str = "") -> dict[str, Any]:
|
||||
state = _read_state()
|
||||
requests = [{"request_id": key, **row} for key, row in (state.get("requests") or {}).items() if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
|
||||
sessions = [{"lock_session_id": key, **row} for key, row in (state.get("sessions") or {}).items() if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
|
||||
audit = [row for row in (state.get("audit") or []) if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)]
|
||||
requests.sort(key=lambda row: float(row.get("created_at") or 0), reverse=True)
|
||||
sessions.sort(key=lambda row: float(row.get("created_at") or 0), reverse=True)
|
||||
audit.sort(key=lambda row: float(row.get("time") or 0), reverse=True)
|
||||
return {"schema": "onec_repository_admin_state.v1", "base_id": base_id or None, "requests": requests, "sessions": sessions, "audit": audit[:200], "counts": {"requests": len(requests), "sessions": len(sessions), "audit": len(audit)}}
|
||||
|
||||
|
||||
def status(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
base_id = str(payload.get("base_id") or "").strip()
|
||||
config, error = repository_config(base_id)
|
||||
if error:
|
||||
return {"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "connected": False, **error}
|
||||
result: dict[str, Any] = {
|
||||
"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id,
|
||||
"status": "configured", "connected": True, "available": None, "repository": _public_config(config),
|
||||
}
|
||||
if not bool(payload.get("probe")):
|
||||
return result
|
||||
if not external_1c_enabled():
|
||||
result["status"] = "sql_only"
|
||||
result["available"] = None
|
||||
result["probe"] = {"status": "not_supported", "message": "External 1C access is disabled in this SQL-only adapter version."}
|
||||
return result
|
||||
if config.get("lock_mode") == "manual":
|
||||
result["status"] = "manual_workflow"
|
||||
result["available"] = None
|
||||
result["probe"] = {"status": "not_applicable", "message": "Manual lock mode does not require Designer or a repository runner. Use repository.lock.plan."}
|
||||
return result
|
||||
timeout_seconds = int(payload.get("timeout_seconds") or 60)
|
||||
executed = _execute_repository(base_id, config, "report", timeout_seconds)
|
||||
result["probe"] = executed
|
||||
result["available"] = executed.get("status") == "ok"
|
||||
result["status"] = "ready" if result["available"] else "blocked_repository_unavailable"
|
||||
return result
|
||||
|
||||
|
||||
def lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
base_id = str(payload.get("base_id") or "").strip()
|
||||
config, error = repository_config(base_id)
|
||||
if error:
|
||||
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, **error}
|
||||
if config.get("lock_mode") == "manual":
|
||||
return {
|
||||
"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id,
|
||||
"status": "manual_action_required", "plan": lock_plan(payload), "next_method": METHOD_CONFIRM,
|
||||
}
|
||||
plan = lock_plan(payload)
|
||||
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
|
||||
plan["status"] = "ready"
|
||||
plan["scope_confirmed"] = True
|
||||
if plan.get("status") != "ready":
|
||||
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "plan": plan}
|
||||
if not payload.get("allow_repository_lock") is True:
|
||||
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "explicit_repository_lock_required", "plan": plan}
|
||||
layer = str(config.get("layer") or "base")
|
||||
with _base_lock(base_id, layer):
|
||||
executed = _execute_repository(base_id, config, "lock", int(payload.get("timeout_seconds") or 120), objects=plan["lock_objects"])
|
||||
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()
|
||||
sessions = state.setdefault("sessions", {})
|
||||
sessions[session_id] = {"base_id": base_id, "layer": layer, "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}
|
||||
|
||||
|
||||
def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
base_id = str(payload.get("base_id") or "").strip()
|
||||
config, error = repository_config(base_id)
|
||||
if error:
|
||||
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
|
||||
if config.get("lock_mode") != "manual":
|
||||
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"}
|
||||
if not external_1c_enabled() and not str(payload.get("request_id") or "").strip():
|
||||
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_required", "next_method": METHOD_LOCK_REQUEST}
|
||||
state = _read_state()
|
||||
request_id = str(payload.get("request_id") or "").strip()
|
||||
request = (state.get("requests") or {}).get(request_id) if request_id else None
|
||||
if request_id and (not isinstance(request, dict) or request.get("base_id") != base_id):
|
||||
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_not_found", "request_id": request_id}
|
||||
if isinstance(request, dict) and 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}
|
||||
effective_payload = dict(payload)
|
||||
if isinstance(request, dict):
|
||||
effective_payload["objects"] = [str(item) for item in request.get("objects") or []]
|
||||
plan = lock_plan(effective_payload)
|
||||
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
|
||||
plan["status"] = "ready"
|
||||
if plan.get("status") != "ready":
|
||||
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "plan": plan}
|
||||
if payload.get("user_confirmed_locked") is not True:
|
||||
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "plan": plan}
|
||||
session_id = "rlock-" + uuid.uuid4().hex
|
||||
state.setdefault("sessions", {})[session_id] = {
|
||||
"base_id": base_id, "layer": str(config.get("layer") or "base"), "backend": config.get("backend"),
|
||||
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
|
||||
"verification": "user_confirmation_only", "automatically_verified": False,
|
||||
}
|
||||
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"])
|
||||
_write_state(state)
|
||||
return {
|
||||
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
|
||||
"status": "manual_confirmed", "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
|
||||
"automatically_verified": False,
|
||||
"warning": "Адаптер принял явное подтверждение пользователя, но не проверял захват через API хранилища.",
|
||||
}
|
||||
|
||||
|
||||
def verify(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
session_id = str(payload.get("lock_session_id") or "").strip()
|
||||
session = (_read_state().get("sessions") or {}).get(session_id)
|
||||
if not isinstance(session, dict):
|
||||
return {"schema": "onec_repository_lock_verify.v1", "method": METHOD_VERIFY, "status": "not_found", "lock_session_id": session_id}
|
||||
verify_status = "owned_by_adapter" if session.get("status") == "acquired" else ("manual_confirmation_unverified" if session.get("status") == "manual_confirmed" else session.get("status"))
|
||||
return {"schema": "onec_repository_lock_verify.v1", "method": METHOD_VERIFY, "status": verify_status, "lock_session_id": session_id, "session": session}
|
||||
|
||||
|
||||
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()
|
||||
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}
|
||||
if session.get("status") != "manual_confirmed":
|
||||
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "blocked", "error": "manual_confirmation_not_active", "lock_session_id": session_id}
|
||||
session["status"] = "closed"
|
||||
session["closed_at"] = time.time()
|
||||
_audit(state, "manual_lock_closed", 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}
|
||||
|
||||
|
||||
def write_gate(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
base_id = str(payload.get("base_id") or "").strip()
|
||||
config, error = repository_config(base_id)
|
||||
if error and error.get("status") == "not_configured":
|
||||
return {"required": False, "allowed": True, "status": "not_configured"}
|
||||
if error:
|
||||
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "problem": error}
|
||||
session_id = str(payload.get("lock_session_id") or "").strip()
|
||||
if not session_id:
|
||||
return {
|
||||
"required": True, "allowed": False, "status": "needs_repository_lock",
|
||||
"backend": config.get("backend"), "next_method": METHOD_LOCK_PLAN,
|
||||
}
|
||||
session = (_read_state().get("sessions") or {}).get(session_id)
|
||||
if not isinstance(session, dict) or session.get("base_id") != base_id or session.get("status") not in {"acquired", "manual_confirmed"}:
|
||||
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "lock_session_id": session_id}
|
||||
target = payload.get("target") if isinstance(payload.get("target"), dict) else {}
|
||||
requested = ""
|
||||
for value in (
|
||||
payload.get("repository_object"), target.get("repository_object"), target.get("canonical_path"),
|
||||
payload.get("canonical_path"), target.get("path"), payload.get("path"), payload.get("ref"), payload.get("object"),
|
||||
):
|
||||
if isinstance(value, str) and value.strip():
|
||||
requested = development_object(value)
|
||||
break
|
||||
if not requested:
|
||||
kind = str(payload.get("object_type") or payload.get("kind") or target.get("object_type") or target.get("kind") or "").strip()
|
||||
name = str(payload.get("object_name") or payload.get("name") or target.get("object_name") or target.get("name") or "").strip()
|
||||
if kind and name:
|
||||
requested = development_object(f"{kind}.{name}")
|
||||
if not requested:
|
||||
return {
|
||||
"required": True, "allowed": False, "status": "blocked_repository_scope_unresolved",
|
||||
"lock_session_id": session_id, "message": "Pass repository_object with the public 1C development-object reference for this low-level write route.",
|
||||
}
|
||||
locked = [str(item) for item in session.get("objects") or []]
|
||||
if requested.casefold() not in {item.casefold() for item in locked}:
|
||||
return {
|
||||
"required": True, "allowed": False, "status": "blocked_repository_scope_mismatch",
|
||||
"lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
|
||||
}
|
||||
return {
|
||||
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"),
|
||||
"lock_session_id": session_id, "requested_object": requested, "objects": locked,
|
||||
"verification": "automatic" if session.get("status") == "acquired" else "user_confirmation_only",
|
||||
}
|
||||
|
||||
|
||||
def commit_plan(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
session_id = str(payload.get("lock_session_id") or "").strip()
|
||||
session = (_read_state().get("sessions") or {}).get(session_id)
|
||||
if not isinstance(session, dict):
|
||||
return {"schema": "onec_repository_commit_plan.v1", "method": METHOD_COMMIT_PLAN, "status": "not_found", "lock_session_id": session_id}
|
||||
comment = str(payload.get("comment") or "").strip()
|
||||
problems: list[dict[str, str]] = []
|
||||
if session.get("status") != "acquired":
|
||||
problems.append({"code": "lock_session_not_acquired", "message": "Commit requires an active adapter lock session."})
|
||||
if not comment:
|
||||
problems.append({"code": "commit_comment_required", "message": "A non-empty repository version comment is required."})
|
||||
return {
|
||||
"schema": "onec_repository_commit_plan.v1", "method": METHOD_COMMIT_PLAN,
|
||||
"status": "ready" if not problems else "blocked", "allowed": not problems,
|
||||
"lock_session_id": session_id, "base_id": session.get("base_id"),
|
||||
"objects": session.get("objects") or [], "comment": comment, "problems": problems,
|
||||
}
|
||||
|
||||
|
||||
def commit(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
plan = commit_plan(payload)
|
||||
session_id = str(payload.get("lock_session_id") or "").strip()
|
||||
if not plan.get("allowed"):
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "lock_session_id": session_id, "plan": plan}
|
||||
if payload.get("allow_repository_commit") is not True:
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "explicit_repository_commit_required", "lock_session_id": session_id, "plan": plan}
|
||||
state = _read_state()
|
||||
session = (state.get("sessions") or {}).get(session_id)
|
||||
base_id = str(session.get("base_id") or "")
|
||||
config, error = repository_config(base_id)
|
||||
if error:
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "lock_session_id": session_id, **error}
|
||||
with _base_lock(base_id, str(session.get("layer") or "base")):
|
||||
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}
|
||||
|
||||
|
||||
def unlock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
session_id = str(payload.get("lock_session_id") or "").strip()
|
||||
state = _read_state()
|
||||
session = (state.get("sessions") or {}).get(session_id)
|
||||
if not isinstance(session, dict):
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "not_found", "lock_session_id": session_id}
|
||||
if session.get("status") != "acquired":
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": str(session.get("status")), "lock_session_id": session_id}
|
||||
if not payload.get("allow_repository_unlock") is True:
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "explicit_repository_unlock_required", "lock_session_id": session_id}
|
||||
base_id = str(session.get("base_id") or "")
|
||||
config, error = repository_config(base_id)
|
||||
if error:
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "lock_session_id": session_id, **error}
|
||||
with _base_lock(base_id, str(session.get("layer") or "base")):
|
||||
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}
|
||||
|
||||
|
||||
def call(method: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if method == METHOD_STATUS:
|
||||
return status(payload)
|
||||
if method == METHOD_LOCK_PLAN:
|
||||
return lock_plan(payload)
|
||||
if method == METHOD_LOCK_REQUEST:
|
||||
return create_lock_request(payload)
|
||||
if method == METHOD_LOCK_REQUEST_STATUS:
|
||||
return lock_request_status(payload)
|
||||
if method == METHOD_LOCK_REQUEST_CANCEL:
|
||||
return cancel_lock_request(payload)
|
||||
if method == METHOD_LOCK:
|
||||
return lock(payload)
|
||||
if method == METHOD_CONFIRM:
|
||||
return confirm_manual_lock(payload)
|
||||
if method == METHOD_VERIFY:
|
||||
return verify(payload)
|
||||
if method == METHOD_CLOSE:
|
||||
return close_manual_lock(payload)
|
||||
if method == METHOD_UNLOCK:
|
||||
return unlock(payload)
|
||||
if method == METHOD_COMMIT_PLAN:
|
||||
return commit_plan(payload)
|
||||
if method == METHOD_COMMIT:
|
||||
return commit(payload)
|
||||
return {"status": "method_not_found", "method": method}
|
||||
@@ -0,0 +1,42 @@
|
||||
id: onec-adapter-connector
|
||||
name: 1C Adapter Connector
|
||||
version: 0.1.0
|
||||
status: standalone-ready
|
||||
owner: local-llm-platform
|
||||
runtime:
|
||||
language: python
|
||||
entrypoint: adapter_1c_server.py
|
||||
host_env: ONEC_ADAPTER_HOST
|
||||
port_env: ONEC_ADAPTER_PORT
|
||||
default_port: 8011
|
||||
contracts:
|
||||
openapi: contracts/openapi.yaml
|
||||
policies:
|
||||
- policies/sql-base-access-policy.yaml
|
||||
- policies/xml-decoding-reference-policy.yaml
|
||||
- policies/designer-sql-decoding-policy.yaml
|
||||
- policies/read-only-query.yaml
|
||||
- policies/change-workflow.yaml
|
||||
- policies/config-layer-write-policy.yaml
|
||||
dependencies:
|
||||
python:
|
||||
- pymssql==2.3.2
|
||||
local_packages:
|
||||
- ../parser
|
||||
data_dirs:
|
||||
cache: /data/adapter-cache.sqlite
|
||||
backups: /data/adapter-apply-backups
|
||||
write_learning: /data/adapter-write-learning
|
||||
security:
|
||||
secrets_policy: "Each base_id stores its SQL server, database, login, and password/password_env in ONEC_SQL_BASES_JSON or a mounted ONEC_SQL_BASES_JSON_FILE; do not store SQL passwords in repository files."
|
||||
api_auth: "Set ONEC_ADAPTER_SERVICE_TOKEN and pass it to clients as ONEC_ADAPTER_TOKEN. /health remains unauthenticated."
|
||||
sql_identity: "Use only the existing credentials from the selected base_id settings. Never create, alter, or drop SQL logins, users, roles, grants, denies, or revokes."
|
||||
data_access: "Application data and metadata structure are read-only. No application-data table may be changed."
|
||||
active_layers: "Config and ConfigCAS are read-only in adapter workflows."
|
||||
writable_layers: "Only ConfigSave and ConfigCASSave are staging targets."
|
||||
health:
|
||||
local_contracts:
|
||||
- ../../../scripts/check_1c_write_plan_contract.py
|
||||
- ../../../scripts/check_1c_extension_action_contract.py
|
||||
- ../../../scripts/check_1c_module_origin_contract.py
|
||||
- ../../../scripts/check_1c_code_symbol_contract.py
|
||||
Reference in New Issue
Block a user