Initial project import

This commit is contained in:
2026-08-14 09:40:51 +03:00
parent 00040e5ce4
commit d7099bf80d
146 changed files with 30509 additions and 1055 deletions
+11
View File
@@ -33,8 +33,15 @@ ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite
ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite
# Legacy JSON job store is read once for migration only.
ONEC_ADAPTER_JOB_STORE=/data/adapter-jobs.json
# Repository requests, confirmations, sessions, and audit are stored in
# ONEC_ADAPTER_STATE_DB. This legacy JSON is imported once and never updated.
ONEC_REPOSITORY_STATE_FILE=/data/onec-repository-locks.json
ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups
ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning
# Activation requests/events are stored in ONEC_ADAPTER_STATE_DB.
# Legacy JSON is read once for migration only and is never updated afterwards.
ONEC_CONFIGURATION_ACTIVATION_STATE_FILE=/data/onec-configuration-activation-requests.json
ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS=1800
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180
@@ -43,3 +50,7 @@ ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true
# Optional POSIX child-process limits; 0 keeps the platform/container limit.
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0
# Stack traces are hidden from REST/MCP clients unless these test/debug flags
# are explicitly enabled.
ONEC_ADAPTER_DEBUG_DIAGNOSTICS=false
ONEC_MCP_DEBUG_DIAGNOSTICS=false
+2
View File
@@ -3,7 +3,9 @@ FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir pymssql==2.3.2
COPY connector/adapter_1c_server.py /app/adapter_1c_server.py
COPY connector/analyze_audit.py /app/analyze_audit.py
COPY connector/repository_control.py /app/repository_control.py
COPY connector/write /app/write
COPY connector/admin /app/admin
COPY parser /app/parser
+168 -4
View File
@@ -6,8 +6,9 @@ The connector is read-first and optimized for an operational coding loop where f
Preferred live architecture:
- read-only SQL connector for fast diagnostics and data samples;
- lightweight 1C agent for metadata, forms, commands, and BSL modules;
- SQL-only connector for diagnostics, metadata decoding, and controlled
saved-state work in an explicitly authorised test base;
- a human-operated Configurator for viewing and applying pending changes;
- cached metadata/module snapshots with freshness checks;
- change proposals as reviewable artifacts, not direct production writes.
@@ -17,7 +18,12 @@ The connector is responsible for:
- BSL module search/read;
- read-only query validation and execution;
- metadata/module snapshots;
- change proposals without direct apply.
- change proposals and, only where a reverse codec is activation-proven,
controlled `ConfigSave`/`ConfigCASSave` writes with rollback evidence.
The adapter never writes `Config`, `ConfigCAS`, or application data directly.
It does not automate Configurator and must not invent unknown 1C structures.
The protocol evidence base is [docs/1c-sql-protocol](../../../docs/1c-sql-protocol/README.md).
Contracts:
@@ -122,6 +128,57 @@ adapter-owned lock session is supplied. Structural add/delete/rename plans are
kept blocked for confirmation because parent and reference objects can also be
required.
## Configuration activation debug workflow
Activation is a separate boundary from saved-state writes and repository
coordination. The current workflow is intentionally debug-only:
1. `configuration.activation.status`;
2. `configuration.activation.plan`;
3. `configuration.activation.request`;
4. forward the returned request id to `configuration.activation.execute` with
`mode=debug` and `confirm_activation=true`;
5. inspect or cancel the request through
`configuration.activation.request.status`,
`configuration.activation.request.cancel`, and
`configuration.activation.audit`.
The request is bound to a live-SQL fingerprint and is rejected when pending
files change or the request expires. Requests and events are stored in the
adapter-local SQLite selected by `ONEC_ADAPTER_STATE_DB`; they contain no
payload bytes or credentials. `ONEC_CONFIGURATION_ACTIVATION_STATE_FILE` is a
one-time legacy JSON import source only. `configuration.activation.capabilities`
reports runner readiness without returning paths, URLs, selectors, users,
passwords, or tokens.
`configuration.activation.bridge.probe` can then check the local runner or the
authenticated HTTP runner endpoint `/configuration/activation/debug`. The
probe verifies only Designer-file availability and infobase-selector presence;
it never starts a process.
Pass `bridge_debug=true` to `configuration.activation.execute` when the runner
must also acknowledge the exact request id and live-SQL fingerprint. The runner
returns an opaque SHA-256 debug receipt; mismatched or missing receipts block
the request, while a valid receipt adds a `bridge_debug_accepted` audit event.
After a manual F7, call `configuration.activation.verify` with the same request
id. It reports `not_activated`, `changed_since_request`, or
`verified_up_to_date` from a fresh SQL comparison. The last status proves
saved/active alignment, not the historical fact that Designer performed the
activation.
Real Designer execution remains disabled. `/UpdateDBCfg` is recorded only as
the documented future base-configuration operation. Extension activation stays
manual until a separately verified platform command and post-activation check
are implemented.
Activation request mutations use SQLite `BEGIN IMMEDIATE` transactions, so
concurrent adapter processes cannot overwrite each other's request/event
updates. Saved-state backup retention is explicit:
`storage.saved_state.backups.prune` defaults to a dry run, is scoped by
`base_id`, preserves the newest requested count, and requires
`confirm_delete=true` before deleting adapter-local backup files. Backups
referenced by `metadata.write.history` are always protected; when write-history
availability cannot be verified, affected backup files are protected
fail-closed.
## Docker Run
Create a local `.env` from `.env.example`, keep real passwords outside git, and
@@ -237,6 +294,8 @@ Current live methods:
- `metadata.route.resolve`
- `metadata.form.decode`
- `metadata.object.attributes`
- `metadata.relationship.verify`
- `metadata.relationship.find`
- `metadata.object.full`
- `metadata.snapshot`
- `codec.decode`
@@ -287,6 +346,9 @@ Agent-facing code write rule:
public path such as `<extension>.<form>.<routine>` plus full code text.
- `code.write` automatically targets the saved-state layer and reports
`write_mode.target=saved_state` with `activation_state=not_activated`.
- Write plans for embedded form-container modules return a ready
`code.write` hint; they do not incorrectly request a nonexistent
`#stream:<index>`.
- Use `code.read`/`code.search` with the default working state for current
programming-time code; use `state=both` only when an explicit saved vs active
comparison is needed.
@@ -296,6 +358,10 @@ without physical SQL/storage traces by default. `metadata.object.decode` also
returns a 1C-facing decoded object profile by default; pass
`include_storage=true` only when adapter diagnostics need the underlying decoded
payload metadata, record containers, or DBNames/storage routes.
Exact extension objects use the same public `kind` + `name`/`ref` selectors as
base objects. `metadata.object.modules` includes owned form modules and returns
qualified names such as
`test2.Форма.t_Форма.Модуль формы`; extension GUIDs and CAS keys remain internal.
`metadata.object.properties` is the unified property endpoint for every 1C
metadata kind. It selects a kind-specific SQL decoder for `Configuration`,
@@ -348,6 +414,32 @@ inspection for user-facing answers. The object can be selected by `guid`, by
`kind` + `name`, or by 1-based `ordinal` within `metadata.objects.list` for that
kind.
For a safe answer to "are these objects linked?", do not infer a link from a
similar field name, BSL mention, or a runtime value. Use
`metadata.relationship.verify` with an exact source `member` and optional
`target_ref`. It returns `confirmed` only when that member's declared 1C type
explicitly names the target object; otherwise it returns `not_confirmed` or an
explicitly ambiguous result. To discover a direct typed field without knowing
its name, call `metadata.relationship.find` with public refs only:
```json
{
"method": "metadata.relationship.find",
"payload": {
"base_id": "upo_test",
"ref": "Document.СписаниеЗапасов",
"target_ref": "Document.РасходнаяНакладная",
"direction": "either",
"execution_mode": "job"
}
}
```
`direction=either` checks both objects for explicitly declared references and
returns the direction of every confirmed edge. A `not_found` result means that
no direct declared metadata reference was found; it does not prove that an
indirect BSL, query, form, or business-process relationship is absent.
`metadata.object.full` is the preferred high-level method for agent answers like
"show everything about this document". It combines the live object card,
semantic sections, decoded forms, BSL module profiles, and counts in one
@@ -488,7 +580,33 @@ unless `include_storage=true`.
`code.search` is the agent-facing wrapper over module search. Its items include
`read_selector.method: "code.read"` and preserve `module_ref` when that is the
best available safe handle. `code.read` can consume that selector directly.
best available safe handle. `code.read` can consume that selector directly;
the selector pins the configuration view that produced the hit. A storage
stream whose Configurator-tree role is not independently decoded is returned
as `bsl_module` with `role_status=unconfirmed` and must not be treated as a
command, manager, or a tree path.
`metadata.object.commands` resolves an `extension` name to the active
extension internally before it reads the selected object. A caller provides
only the public object and extension selectors; it must not replace them with
a base-configuration route or infer a command from a BSL stream suffix. A
successful empty command list is the only evidence currently returned for “no
decoded commands”; an unresolved object route is reported separately.
For object-owned extension forms, `modules.search` and `code.search` resolve
the form module from the public owner reference. In the default
`state=working` view they inspect the saved counterpart first and fall back to
the active module only when needed; `state=active` never returns saved-only
text. Saved matches carry `activation_state=saved_state` and
`current_state.activation_state=not_activated`.
For an active extension form selector, `code.read state=both` resolves the
saved form by logical owner/form identity, even when active and saved CAS file
names differ, and reports live text SHA1 comparison evidence.
`metadata.resolve_overrides` uses the same name-first form ownership and
saved-first working-state rules. A public selector such as `Catalog.test2`
therefore resolves routines located in forms owned by that extension object;
the returned chain identifies the form and activation state without exposing
the object's physical SQL route.
`metadata.definition.find` accepts public object references such as
`Обработка.<Name>` or `Document.<Name>` in `query` and the common object
@@ -553,3 +671,49 @@ be proven, the adapter must re-read live SQL or return an explicit stale-cache
error.
Operational runbook: `docs/runbooks/1c-operational-coding.md`.
## Development audit telemetry
Every REST `/rpc` call produces a privacy-safe JSONL event in
`/data/adapter-audit.jsonl`. It contains the UTC time, correlation id, public
method and selector summary, result status/error, duration, public route and
resolver timings/counts (when a write route is involved), and exception type
when the request itself fails. A `public_write_route_unresolved` event retains
the safe resolver status/error/candidate count so it can be diagnosed without
asking a caller for a module handle. It deliberately excludes BSL text, SQL
payloads, physical file names, stream indexes, credentials, and SQL connection
details. The MCP proxy forwards its generated
request id in `X-Request-ID`, so an agent response can be correlated with the
REST record. The log is shared by all configured
`base_id` values so cross-base failures and slow calls can be compared.
For development, the default retention is deliberately generous: 50 MiB per
file and ten retained files. Configure `ONEC_ADAPTER_AUDIT_MAX_BYTES` and
`ONEC_ADAPTER_AUDIT_KEEP_FILES` to change it. Rotation is best-effort and can
never fail an adapter request. A caller may supply an `X-Request-ID` header to
correlate a client event with the REST record.
The `adapter-1c-audit` Compose service writes an aggregate report every 15
minutes to `/data/adapter-audit-reports/latest.json`; set
`ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS` to alter the interval. It reports base
distribution, failures, slow operations, malformed rows, and recent failures.
For an immediate manual report, run `python scripts/analyze_1c_adapter_audit.py`
against a copied log or `python /app/analyze_audit.py` inside the REST image.
The MCP proxy has its own persistent `/data/mcp-audit.jsonl` and periodic
summary: it records failures that happen before a request reaches REST.
For an extension-wide `code.search` without a concrete object selector,
`timeout_seconds` is a total search budget. If owner-route discovery consumes
that budget, the adapter returns `status=partial` with
`diagnostics.code=time_budget_exhausted`; it does not continue serial owner
probes in the background. Narrow routine work with `ref` or `kind`/`name`.
REST deployments use a five-minute Docker stop grace period. On `SIGTERM` the
adapter stops accepting new work and waits for already-running request threads,
including verified saved-state writes, to complete. Do not deploy the REST
service while an operator is intentionally running a production-base write;
the deployment prevents a half-response, but the client should still retry only
after it receives a structured result.
The deployment script also waits for `health.runtime.active_rpc_count=0` before
recreating REST. `-SkipDrainCheck` is an emergency-only override and must not
be used while a write is in progress.
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
"""Summarize privacy-safe adapter JSONL telemetry inside the REST image."""
from __future__ import annotations
import argparse
import json
from collections import Counter
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--log", default="/data/adapter-audit.jsonl")
parser.add_argument("--slow-ms", type=int, default=5_000)
parser.add_argument("--limit", type=int, default=20)
args = parser.parse_args()
path = Path(args.log)
rows: list[dict] = []
malformed_rows = 0
if path.exists():
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
try:
item = json.loads(line)
except json.JSONDecodeError:
malformed_rows += 1
continue
if item.get("event") == "adapter_rpc":
rows.append(item)
by_base = Counter(str((row.get("request") or {}).get("base_id") or "<none>") for row in rows)
exceptions = [row for row in rows if str(row.get("status") or "") == "exception" or row.get("error") == "request_exception"]
rejected = [row for row in rows if str(row.get("status") or "") in {"blocked", "unsupported", "invalid_argument"}]
slow = sorted((row for row in rows if int(row.get("duration_ms") or 0) >= args.slow_ms), key=lambda row: int(row.get("duration_ms") or 0), reverse=True)
print(json.dumps({
"schema": "onec_adapter_audit_summary.v1",
"status": "ok" if path.exists() else "log_not_found",
"events": len(rows), "malformed_rows": malformed_rows,
"time_range": {"from": rows[0].get("time") if rows else None, "to": rows[-1].get("time") if rows else None},
"bases": dict(by_base), "adapter_exceptions": len(exceptions),
"expected_rejections": len(rejected),
"exception_methods": dict(Counter(str(row.get("method") or "<none>") for row in exceptions).most_common(args.limit)),
"slow_threshold_ms": args.slow_ms,
"slow": [{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} for row in slow[:args.limit]],
"recent_exceptions": [{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "exception_type": row.get("exception_type"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} for row in exceptions[-args.limit:]],
"findings": [
*([{"priority": "P1", "kind": "adapter_exception", "count": len(exceptions), "next_action": "Inspect the matching REST request_id and exception_type; reproduce only on upo_test before changing code."}] if exceptions else []),
*([{"priority": "P2", "kind": "slow_calls", "count": len(slow), "next_action": "Inspect timings_ms for the listed methods; optimise only after a repeated pattern is confirmed."}] if slow else []),
*([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect log rotation and container shutdown events."}] if malformed_rows else []),
],
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+375 -4
View File
@@ -54,6 +54,140 @@ paths:
schema:
type: object
additionalProperties: true
/configuration/activation/status:
post:
operationId: getConfigurationActivationStatus
summary: Read the live saved-state to active boundary
description: Compares saved-state and active configuration layers without cache, vector search, Designer execution, or configuration mutation.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationRequest"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/plan:
post:
operationId: planConfigurationActivation
summary: Build a read-only activation handoff plan
description: Returns review and verification calls plus a manual Designer action when activation is required. It never starts Designer.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationRequest"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/request:
post:
operationId: createConfigurationActivationRequest
summary: Create a fingerprinted activation request
description: Persists an expiring adapter-local request bound to the exact live-SQL saved-state fingerprint. It does not start Designer.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationRequestCreate"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/request/status:
post:
operationId: getConfigurationActivationRequest
summary: Read activation request state
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationRequestStatus"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/request/cancel:
post:
operationId: cancelConfigurationActivationRequest
summary: Cancel one activation request
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationRequestCancel"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/audit:
post:
operationId: auditConfigurationActivationRequests
summary: List activation request lifecycle events for one base
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationAudit"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/capabilities:
post:
operationId: getConfigurationActivationCapabilities
summary: Read safe Designer bridge readiness
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationCapabilities"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/bridge/probe:
post:
operationId: probeConfigurationActivationBridge
summary: Probe local or HTTP Designer runner readiness without execution
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationBridgeProbe"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/execute:
post:
operationId: debugConfigurationActivation
summary: Accept a fingerprinted activation request in debug mode
description: Revalidates the exact live-SQL fingerprint and records debug acceptance. Real Designer execution is unavailable.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationExecute"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/configuration/activation/verify:
post:
operationId: verifyConfigurationActivation
summary: Verify saved/active alignment for one activation request
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigurationActivationVerify"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/extensions:
get:
operationId: listExtensions
@@ -258,7 +392,7 @@ paths:
type: string
enum: [Params, Config, ConfigSave, ConfigCAS, ConfigCASSave]
default: Config
description: Storage table to read from. Use Config for active config, ConfigSave for saved config.
description: Storage table to read from. Exact public extension selectors resolve to ConfigCAS internally; physical routes remain hidden unless include_storage=true.
guid:
type: string
description: Config object GUID. If omitted, kind and name are used.
@@ -451,7 +585,7 @@ paths:
type: string
enum: [Params, Config, ConfigSave, ConfigCAS, ConfigCASSave]
default: Config
description: Storage table to read from. Use Config for active config, ConfigSave for saved config.
description: Storage table to read from. Exact public extension selectors resolve to ConfigCAS internally; physical routes remain hidden unless include_storage=true.
guid:
type: string
description: Config object GUID. If omitted, kind and name are used.
@@ -461,7 +595,7 @@ paths:
type: string
responses:
"200":
description: Live BSL module stream ids for a metadata object.
description: Public BSL modules owned by the selected object, including owned form modules. Exact extension objects are resolved from public kind/name or ref.
/metadata/object/related:
post:
operationId: listMetadataObjectRelated
@@ -958,6 +1092,11 @@ paths:
ref:
type: string
description: Public object reference such as Справочник.Номенклатура.
state:
type: string
enum: [working, active, save, both]
default: working
description: Working is saved-first with active fallback, including form modules owned by extension objects resolved from the public selector.
responses:
"200":
description: Read-only routine override/action chain across base and extension modules.
@@ -983,6 +1122,9 @@ paths:
source:
type: string
enum: [configuration, extension]
activation_state:
type: string
enum: [active, saved_state]
method:
type: string
line_start:
@@ -998,7 +1140,7 @@ paths:
enum: [ok, unknown]
operation_class:
type: string
description: base_definition, insert_before, insert_after, replace, replace_with_control, or unknown_extension_action.
description: base_definition, extension_definition, insert_before, insert_after, replace, replace_with_control, or unknown_extension_action.
requires_control_fragment:
type: boolean
extension_actions:
@@ -1208,6 +1350,20 @@ paths:
module_ref:
type: string
description: Opaque module_ref from code.search/modules.search read_selector when already known.
expected_sha1:
type: string
description: Optional container SHA1 precondition, normally forwarded from metadata.write.preflight write_context.
expected_text_sha1:
type: string
description: Optional BSL text SHA1 precondition, normally forwarded from metadata.write.preflight write_context.
repository_lock:
type: object
additionalProperties: true
description: Forwardable repository.lock.confirm write_context.
write_context:
type: object
additionalProperties: true
description: Forwardable context returned by metadata.write.preflight.
routine_name:
type: string
routine_text:
@@ -1483,6 +1639,26 @@ paths:
required: true
schema:
type: string
- name: routine_name
in: query
required: false
schema:
type: string
description: Select one BSL procedure/function. Its text is compact by default.
- name: include_routines
in: query
required: false
schema:
type: boolean
default: false
description: Include the routine catalogue when a routine is selected.
- name: include_summary
in: query
required: false
schema:
type: boolean
default: false
description: Include the module summary when a routine is selected.
responses:
"200":
description: BSL module content.
@@ -1725,6 +1901,51 @@ paths:
responses:
"200":
description: Saved-state apply backups list with source metadata and sha1/byte counts. Payload hex is not returned.
/storage/saved-state/backups/prune:
post:
operationId: pruneSavedStateBackups
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [base_id]
properties:
base_id:
type: string
table:
type: string
enum: [ConfigSave, ConfigCASSave]
file_name:
type: string
older_than_days:
type: integer
minimum: 1
maximum: 3650
default: 30
keep_latest:
type: integer
minimum: 0
maximum: 10000
default: 20
limit:
type: integer
minimum: 1
maximum: 10000
default: 500
dry_run:
type: boolean
default: true
confirm_delete:
type: boolean
default: false
diagnostic:
type: boolean
description: Required when called through the generic MCP diagnostic policy.
responses:
"200":
description: Dry-run selection or confirmed deletion of adapter-local saved-state backup files. Backups referenced by write history are always protected, with fail-closed protection when history cannot be verified.
/access/graph:
post:
operationId: buildAccessGraph
@@ -2246,6 +2467,156 @@ components:
type: http
scheme: bearer
schemas:
ConfigurationActivationRequest:
type: object
required: [base_id]
additionalProperties: false
properties:
base_id:
type: string
layer:
type: string
enum: [all, base_saved_state, extension_saved_state]
default: all
limit:
type: integer
minimum: 1
maximum: 5000
default: 5000
timeout_seconds:
type: integer
minimum: 1
default: 30
include_files:
type: boolean
default: false
include_storage:
type: boolean
default: false
ConfigurationActivationRequestCreate:
type: object
required: [base_id]
additionalProperties: false
properties:
base_id:
type: string
layer:
type: string
enum: [all, base_saved_state, extension_saved_state]
default: all
limit:
type: integer
minimum: 1
maximum: 5000
default: 5000
timeout_seconds:
type: integer
minimum: 1
default: 30
ttl_seconds:
type: integer
minimum: 60
maximum: 86400
default: 1800
ConfigurationActivationRequestStatus:
type: object
required: [request_id]
additionalProperties: false
properties:
base_id:
type: string
request_id:
type: string
ConfigurationActivationRequestCancel:
type: object
required: [request_id, confirm_cancel]
additionalProperties: false
properties:
base_id:
type: string
request_id:
type: string
confirm_cancel:
type: boolean
const: true
ConfigurationActivationAudit:
type: object
required: [base_id]
additionalProperties: false
properties:
base_id:
type: string
limit:
type: integer
minimum: 1
maximum: 1000
default: 100
status:
type: string
ConfigurationActivationCapabilities:
type: object
required: [base_id]
additionalProperties: false
properties:
base_id:
type: string
layer:
type: string
enum: [all, base_saved_state, extension_saved_state]
default: all
ConfigurationActivationBridgeProbe:
type: object
required: [base_id]
additionalProperties: false
properties:
base_id:
type: string
layer:
type: string
enum: [all, base_saved_state, extension_saved_state]
default: all
timeout_seconds:
type: integer
minimum: 1
maximum: 60
default: 10
ConfigurationActivationExecute:
type: object
required: [base_id, request_id, confirm_activation]
additionalProperties: false
properties:
base_id:
type: string
request_id:
type: string
mode:
type: string
const: debug
default: debug
confirm_activation:
type: boolean
const: true
bridge_debug:
type: boolean
default: false
description: Require an end-to-end local/HTTP runner debug receipt without starting Designer.
timeout_seconds:
type: integer
minimum: 1
default: 30
ConfigurationActivationVerify:
type: object
required: [base_id, request_id]
additionalProperties: false
properties:
base_id:
type: string
request_id:
type: string
timeout_seconds:
type: integer
minimum: 1
default: 30
AdapterRpcRequest:
type: object
required: [method]
@@ -0,0 +1,164 @@
# Исследования SQL: активность расширений
Статус: `in_progress`. Этот журнал отделяет наблюдения от гипотез. Ничего из
раздела «гипотеза» не используется адаптером как runtime-правило.
## 2026-08-02 — baseline `upo_test`
Цель: установить доказанный SQL-признак флажка «Активно» расширения в
Конфигураторе.
Снимок выполнен только чтением из `dbo._ExtensionsInfo` для имён
`фс_Отчеты` и `фс_Отчеты1`. Зафиксированы все известные скалярные поля строки
и SHA1 `_ExtensionZippedInfo`; бинарные данные и учётные сведения не сохранены.
| name | `_IDRRef` (hex) | `_ExtensionOrder` | `_UpdateTime` | `_ExtensionUsePurpose` | `_ExtensionScope` | zipped bytes | zipped SHA1 |
| --- | --- | ---: | --- | ---: | ---: | ---: | --- |
| `фс_Отчеты` | `0x8287005056B0D48311F13D089B11F844` | 21 | 4026-05-07 23:45:25 | 2 | 1 | 188 | `0D07CB6CE05AADB301A002CAB7312AD6EA64A344` |
| `фс_Отчеты1` | *строка отсутствует* | — | — | — | — | — | — |
### Подтверждено
- Наличие строки в `_ExtensionsInfo` доказывает регистрацию расширения в
данном SQL-снимке, но **не** доказывает флажок «Активно» в Конфигураторе.
- Поэтому поле `active` адаптера для такого источника возвращается как
`null`; прежнее значение `true` было неподтверждённым и удалено.
### Гипотеза, требующая проверки
После переключения флажков в Конфигураторе изменится одна или несколько
наблюдаемых SQL-структур: строка `_ExtensionsInfo`, поля строки, DBNames-Ext,
ConfigCAS/ConfigCASSave или иной live SQL-маркер.
### Следующий контролируемый опыт
1. Пользователь активирует `фс_Отчеты` и выключает `фс_Отчеты1` (либо наоборот)
в Конфигураторе и сообщает, когда действие сохранено.
2. Адаптер снимает тот же снимок `_ExtensionsInfo` и дополнительно сравнивает
только подтверждённые live SQL-маркеры.
3. Правило будет добавлено в runtime лишь если различие воспроизводится при
обратном переключении и однозначно связано с активной композицией.
### Запрещённый вывод до опыта
Нельзя отбрасывать расширение из поиска только по факту его наличия или
отсутствия в `_ExtensionsInfo`, по совпадающему GUID объекта либо по догадке
из названия/порядка расширения.
## 2026-08-02 — baseline `upo` (текущий опыт)
Этот снимок является исходной точкой для переключения, которое пользователь
будет выполнять в `upo`. Он не смешивается с наблюдением `upo_test` выше.
| name | `_IDRRef` (hex) | GUID из `_IDRRef` | `_ExtensionOrder` | `_UpdateTime` | `_ExtensionUsePurpose` | `_ExtensionScope` | zipped bytes | zipped SHA1 |
| --- | --- | --- | ---: | --- | ---: | ---: | ---: | --- |
| `фс_Отчеты` | `0xA0CF005056B59ABC11F13D592F0651F1` | `2f0651f1-3d59-11f1-a0cf-005056b59abc` | 20 | 2001-01-01 00:00:00 | 2 | 1 | 188 | `2718A3CC564F593799EFCF1E716305358AF804E8` |
| `фс_Отчеты1` | `0x8294005056B0D48311F18A348E02ACCD` | `8e02accd-8a34-11f1-8294-005056b0d483` | 22 | 4026-08-01 18:51:55 | 2 | 1 | 188 | `A9A4AF42BBA2084141A122F9D7C39D7E1428CB21` |
На baseline присутствуют обе строки. Значит наличие в `_ExtensionsInfo` не
может быть критерием активности: оно не отличает выключенное `фс_Отчеты` от
включенного `фс_Отчеты1` на скриншоте пользователя.
## 2026-08-02 — подтверждённый декодер активности
Три независимых переключения флажка в Конфигураторе, сохранённые пользователем
в `upo`, дали один и тот же результат. Не весь SHA1, а **третий байт с конца**
`_ExtensionZippedInfo` меняется вместе с флажком:
| расширение | длина контейнера | состояние в UI | третий байт с конца |
| --- | ---: | --- | --- |
| `фс_Отчеты` | 188 | выключено → включено | `81``82` |
| `фс_Отчеты1` | 188 | включено → выключено | `82``81` |
| `ЭкстракторДанных1СВBI` | 215 | включено → выключено | `82``81` |
Другие изменения контейнера не являются маркером: например, байт около начала
контейнера и весь SHA1 меняются при сохранении Конфигуратором.
### Runtime-правило (подтверждено для наблюдаемой версии)
`SUBSTRING(_ExtensionZippedInfo, DATALENGTH(_ExtensionZippedInfo) - 2, 1)`:
- `0x82` — расширение активно;
- `0x81` — расширение выключено;
- любое иное значение — `active: null`, `unresolved`.
Правило декодирует только активность и не интерпретирует остальные байты
контейнера. Перед использованием для фильтрации глобального поиска требуется
отдельный regression-тест, что выключенная extension route не попадает в
`effective_working` code search/read.
## 2026-08-02 — расширенная матрица флажков
В Конфигураторе была показана полная таблица расширений с дополнительными
флажками: безопасный режим, защита от опасных действий, использование в
распределённой ИБ и «использовать основной режим». Их комбинации различаются
между активными расширениями. Повторный read-only снимок `upo` дал:
- 14 расширений с UI-флажком «Активно» получили завершающий байт `82`;
- `ЭкстракторДанных1СВBI` и `фс_Отчеты1` с выключенным «Активно» получили
`81`;
- среди активных строк есть разные состояния каждого показанного
дополнительного флажка, но их завершающий байт всё равно `82`.
### Уточнённый вывод
`81` и `82` надо рассматривать как два **наблюдаемых кода состояния
активности** в третьем байте с конца, а не как полную структуру битовых
флажков расширения. Технически это может быть битовое поле, перечисление или
маркер внутри более крупного протокола — формат этого байта пока не доказан.
Для runtime достаточно точного соответствия `81`/`82`; никаких выводов о
других флажках из него делать нельзя.
### Принятое правило адаптера
Рабочая композиция адаптера содержит только строки с `active: true` (`82`).
По умолчанию неактивные и нераспознанные расширения:
- не выдаются методом `extensions.list`;
- не участвуют в DBNames-Ext, ConfigCAS, manifest и cache-маршрутах;
- не участвуют в глобальном поиске модулей и объектов;
- не могут стать целью чтения или записи.
Явная попытка обратиться к известному выключенному расширению завершается
`status: unavailable`, `error: extension_inactive`; адаптер не читает и не
строит маршрут к его объектам. Это исключает неоднозначность одинакового GUID
объекта в активном и выключенном расширениях.
Это правило распространяется и на технические селекторы: верхнеуровневый
`extension_guid`, а также публичный `ConfigCASSave` file route с префиксом
GUID. Публичное чтение `ConfigCAS` разрешено только для ключа, который
подтверждён манифестом активного расширения; непринадлежащий активной
композиции файл получает `extension_route_not_active`. Внутренние SQL-вызовы
адаптера отделены от этого публичного барьера, чтобы он мог доказуемо
построить маршрут, но не раскрывает эти строки агенту.
### Неподтверждённая гипотеза
Остальные флажки записаны в других позициях `_ExtensionZippedInfo` либо в
другой SQL-структуре. Это не используется адаптером.
### Следующий опыт для декодирования остальных флажков (только по необходимости)
На одном выбранном расширении оставить «Активно» неизменным и переключить
ровно один другой флажок, сохранить, затем снять бинарный diff. Повторить
обратное переключение. До двухстороннего воспроизведения позиция и смысл
изменившихся байтов остаются гипотезой.
## Неподтверждённое направление — opaque `module_ref`
Нельзя отбрасывать любой `ConfigCAS`/`ConfigCASSave` `module_ref` только по
имени физического файла: у части подтверждённых активных модулей GUID
расширения отсутствует в имени и восстанавливается только из доказанного
контекста владельца. Ранняя фильтрация такого `module_ref` была проверена и
отменена, так как блокировала активные маршруты. Дальнейшее усиление возможно
только после доказанного owner-resolution до чтения модуля; до этого нельзя
объявлять opaque module_ref маршрутом выключенного расширения или менять его
семантику догадкой.
### Подтверждённое частное правило для `module_ref`
Если physical `ConfigCASSave module_ref` содержит стандартный префикс
`<extension-guid>__`, GUID слоя доказуем до чтения контейнера. Адаптер
проверяет этот GUID по активной композиции и возвращает `extension_inactive`
для выключенного расширения. Это правило не распространяется на непрозрачные
имена файлов без GUID: для них по-прежнему требуется доказательство владельца.
@@ -1,6 +1,6 @@
id: 1c-designer-sql-decoding-policy
status: active
summary: "Controlled changes in a disposable 1C base may be made only through 1C clients; the adapter observes and decodes SQL without writing application data."
summary: "The adapter is a SQL codec only: it decodes and encodes strictly by the live-SQL-derived, versioned configuration-storage specification. In an explicitly authorised test base it may write only verified configuration saved-state overlays. It never invents 1C structure, BSL, or integrity atoms, and never writes active configuration or application data."
scope:
default_base_id: upo_test
@@ -8,8 +8,10 @@ scope:
forbidden_base_class: [production, unclassified]
platform_mutation_authority:
application_data: 1c_enterprise_client
metadata_working_state: 1c_designer
adapter_role: sql_observer_and_decoder
active_configuration: 1c_designer
metadata_saved_state: adapter_sql_only_with_verified_codec
adapter_role: specification_bound_sql_decoder_and_controlled_saved_state_writer
fundamental_rule: "Unknown, incomplete, or ambiguous structure returns explicit evidence and unsupported/partial/ambiguous; no guessed decoding or encoding is permitted."
credentials:
persistence: forbidden_in_repository
@@ -35,7 +37,7 @@ experiment:
forbidden_selectors_for_callers: [sql_number, physical_table, internal_guid_only]
sql_observation:
adapter_access: read_only
adapter_access: sql_only
allowed: [SELECT, metadata_schema_inspection, ConfigSave_read, ConfigCASSave_read, application_table_read]
forbidden:
- direct_application_data_write
@@ -43,7 +45,20 @@ sql_observation:
- direct_ConfigCAS_write
- sql_identity_or_permission_change
- trigger_or_profiler_installation
rule: "All experimental mutations happen through 1C; SQL is evidence, not the mutation transport."
rule: "For production and unclassified bases SQL is evidence only. In the explicitly authorised disposable base, SQL writes are limited to ConfigSave/ConfigCASSave after a proven lossless codec, exact preconditions, atomic paired-file update, backup, and readback verification."
saved_state_write:
allowed_base_id: upo_test
allowed_tables: [ConfigSave, ConfigCASSave]
forbidden_tables: [Config, ConfigCAS]
required:
- "Resolve the target by live public-name evidence; do not require callers to supply a physical selector."
- "Read and hash every target byte stream before writing."
- "Use an exact, unique edit anchor or a proven offset/path selector; otherwise return an ambiguity error."
- "For extension saved-state, update the changed payload and the matching __configinfo file-SHA1 reference atomically."
- "Preserve unproven service atoms byte-for-byte; never generate a value by guesswork or randomness."
- "Create rollback evidence and verify SQL readback after commit."
- "Return Configurator refresh guidance based on whether the object existed in saved-state before the write."
metadata_layers:
designer_save:
@@ -66,4 +81,3 @@ promotion_gates:
- "The rule is reproduced with a second value or a second object of the same shape."
- "A regression fixture and decoder test are added."
- "Rollback through 1C restores the SQL evidence or the experiment documents an irreversible schema migration."
+522 -47
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import json
import hashlib
import os
import re
import sqlite3
import subprocess
import tempfile
import threading
@@ -10,6 +12,7 @@ import time
import urllib.error
import urllib.request
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any
@@ -43,6 +46,7 @@ SUPPORTED_SUPPORT_MODES = {"none", "editable", "locked", "rules", "unknown"}
_BASE_LOCKS: dict[str, threading.Lock] = {}
_BASE_LOCKS_GUARD = threading.Lock()
_STATE_LOCK = threading.RLock()
REPOSITORY_STATE_SCHEMA_VERSION = 2
def external_1c_enabled() -> bool:
@@ -124,7 +128,20 @@ def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, A
else:
item = None
if isinstance(base_item, dict) and isinstance(base_item.get("development_layers"), dict):
return None, layer_error
# A disposable base can explicitly declare that its base layer has
# no repository at all. Extensions in such a base are not new
# repository layers merely because the adapter has discovered them
# after the configuration file was written. Inherit only this
# unambiguous no-repository fact; never inherit a manual/automatic
# repository policy to an extension.
base_layer = base_item["development_layers"].get("base")
base_repository = base_layer.get("repository") if isinstance(base_layer, dict) and isinstance(base_layer.get("repository"), dict) else None
base_mode = str((base_repository or {}).get("mode") or (base_repository or {}).get("lock_mode") or "").strip().casefold()
base_connection = str((base_repository or {}).get("connection_state") or "").strip().casefold()
if layer_id != "base" and (base_mode == "none" or base_connection == "not_configured"):
item = {"mode": "none", "connection_state": "not_configured", "inherited_from_layer": "base"}
else:
return None, layer_error
# Legacy repository-only configuration remains readable for the base layer.
if item is None and layer_id == "base" and isinstance(base_item, dict):
item = base_item.get("repository")
@@ -148,7 +165,11 @@ def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, A
if configured["mode"] == "unknown":
return {"mode": "unknown", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
if configured["mode"] == "none":
return {"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
return {
"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"],
"layer_id": layer_id, "layer": layer_id,
**({"inherited_from_layer": configured["inherited_from_layer"]} if configured.get("inherited_from_layer") else {}),
}, None
configured["backend"] = str(configured.get("backend") or "direct").strip().casefold()
configured["layer_id"] = layer_id
configured["layer"] = layer_id
@@ -207,6 +228,7 @@ def _public_config(config: dict[str, Any]) -> dict[str, Any]:
"layer": config.get("layer"),
"lock_mode": config.get("lock_mode"),
"connection_state": config.get("connection_state"),
"inherited_from_layer": config.get("inherited_from_layer"),
"adapter_access_mode": "sql_only" if not external_1c_enabled() else "sql_and_external_1c",
"automatic_repository_operations_available": external_1c_enabled(),
"endpoint": config.get("endpoint"),
@@ -317,6 +339,119 @@ def _run_designer(config: dict[str, Any], operation: list[str], timeout_seconds:
}
def activation_debug_probe(
base_id: str,
config: dict[str, Any],
*,
layer: str,
timeout_seconds: int,
request_id: str = "",
fingerprint: str = "",
) -> dict[str, Any]:
"""Probe runner readiness without starting Designer or reading configured credentials."""
runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"}
runner_kind = str(runner.get("kind") or "local").strip().casefold()
if runner_kind == "http":
url = str(runner.get("url") or "").rstrip("/") + "/configuration/activation/debug"
request_body = {
"base_id": base_id,
"layer": layer,
"mode": "debug",
}
if request_id and fingerprint:
request_body["request_id"] = request_id
request_body["fingerprint"] = fingerprint
body = json.dumps(
request_body,
ensure_ascii=False,
).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
token_env = str(runner.get("token_env") or "").strip()
token = os.environ.get(token_env, "") if token_env else ""
if token:
headers["Authorization"] = f"Bearer {token}"
try:
with urllib.request.urlopen(
urllib.request.Request(url, data=body, headers=headers, method="POST"),
timeout=timeout_seconds,
) as response:
result = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
try:
result = json.loads(exc.read().decode("utf-8"))
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
result = {
"status": "runner_error",
"message": f"Activation debug runner returned HTTP {exc.code}.",
}
return result if isinstance(result, dict) else {
"status": "runner_error",
"message": f"Activation debug runner returned HTTP {exc.code}.",
}
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
return {"status": "runner_error", "message": str(exc)}
return result if isinstance(result, dict) else {
"status": "runner_error",
"message": "Activation debug runner returned a non-object response.",
}
infobase = config.get("infobase") if isinstance(config.get("infobase"), dict) else {}
selector_configured = (
sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1
)
designer_path = str(config.get("designer_path") or "").strip()
try:
designer_available = bool(designer_path and Path(designer_path).is_file())
except OSError:
designer_available = False
ready = bool(selector_configured and designer_available)
debug_acceptance = None
if request_id and fingerprint:
receipt_source = json.dumps(
{
"base_id": base_id,
"layer": layer,
"request_id": request_id,
"fingerprint": fingerprint,
"mode": "debug",
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
debug_acceptance = {
"accepted": ready,
"request_id": request_id,
"fingerprint": fingerprint,
"receipt": hashlib.sha256(receipt_source).hexdigest() if ready else None,
}
return {
"schema": "onec_configuration_activation_runner_probe.v1",
"status": "ready" if ready else "not_ready",
"base_id": base_id,
"layer": layer,
"runner": {
"kind": "local",
"reachable": True,
"designer_path_configured": bool(designer_path),
"designer_available": designer_available,
"infobase_selector_configured": selector_configured,
},
"operation": {
"kind": "/UpdateDBCfg" if layer == "base_saved_state" else None,
"execution_supported": False,
"extension_manual_only": layer in {"all", "extension_saved_state"},
},
"debug_acceptance": debug_acceptance,
"execution": {
"mode": "debug",
"performed": False,
"designer_started": False,
"active_configuration_changed": False,
},
}
def _execute_repository(
base_id: str,
config: dict[str, Any],
@@ -483,8 +618,7 @@ def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
}
repository_user = requested_repository_user or configured_repository_user
request_id = "rreq-" + uuid.uuid4().hex
with _STATE_LOCK:
state = _read_state()
with _state_transaction() as state:
state.setdefault("requests", {})[request_id] = {
"base_id": base_id,
"layer": layer_id,
@@ -499,7 +633,6 @@ def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
}
_audit(state, "lock_request_created", request_id=request_id, base_id=base_id, objects=plan["lock_objects"])
_write_state(state)
return {
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
"base_id": base_id, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id,
@@ -521,7 +654,21 @@ def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
request = (_read_state().get("requests") or {}).get(request_id)
if not isinstance(request, dict):
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": "not_found", "request_id": request_id}
result = {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
result = {
"schema": "onec_repository_lock_request_status.v1",
"method": METHOD_LOCK_REQUEST_STATUS,
"status": request.get("status"),
"request_id": request_id,
# Surface the manual-confirmation scope at top level. Requiring
# callers to inspect an opaque persisted request made a pending lock
# look context-free and encouraged unsafe confirmation guesses.
"base_id": request.get("base_id"),
"layer_id": request.get("layer_id") or request.get("layer"),
"objects": list(request.get("objects") or []),
"repository_user": request.get("repository_user") or None,
"native_lock_state": "unknown",
"request": request,
}
if request.get("status") == "pending_user_lock":
result["next_method"] = METHOD_CONFIRM
result["next_call"] = manual_confirmation_next_call(str(request.get("base_id") or ""), request_id)
@@ -532,8 +679,7 @@ def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
request_id = str(payload.get("request_id") or "").strip()
if payload.get("confirm_cancel") is not True:
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "confirmation_required", "request_id": request_id}
with _STATE_LOCK:
state = _read_state()
with _state_transaction() as state:
request = (state.get("requests") or {}).get(request_id)
if not isinstance(request, dict):
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "not_found", "request_id": request_id}
@@ -542,20 +688,284 @@ def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
request["status"] = "cancelled"
request["cancelled_at"] = time.time()
_audit(state, "lock_request_cancelled", request_id=request_id, base_id=request.get("base_id"), objects=request.get("objects"))
_write_state(state)
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "cancelled", "request_id": request_id}
def _state_path() -> Path:
"""Legacy JSON path used only for one-time migration to local SQLite."""
return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json")
def _read_state() -> dict[str, Any]:
def _state_db_path() -> Path:
"""Adapter-local state database; never points at a configured 1C database."""
configured = os.environ.get("ONEC_ADAPTER_STATE_DB") or os.environ.get("ONEC_ADAPTER_CACHE_DB")
if configured:
return Path(configured)
legacy_override = os.environ.get("ONEC_REPOSITORY_STATE_FILE")
if legacy_override:
return Path(legacy_override).with_suffix(".sqlite")
return Path("/data/adapter-cache.sqlite")
def _empty_state() -> dict[str, Any]:
return {"sessions": {}, "requests": {}, "audit": []}
def _read_legacy_state() -> dict[str, Any]:
try:
value = json.loads(_state_path().read_text(encoding="utf-8-sig"))
state = value if isinstance(value, dict) else {"sessions": {}, "requests": {}, "audit": []}
return value if isinstance(value, dict) else _empty_state()
except (OSError, json.JSONDecodeError):
state = {"sessions": {}, "requests": {}, "audit": []}
return _empty_state()
def _state_connection() -> sqlite3.Connection:
path = _state_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA busy_timeout=30000")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS adapter_state_meta (
key TEXT PRIMARY KEY,
value TEXT,
updated_at REAL NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS repository_lock_requests (
request_id TEXT PRIMARY KEY,
base_id TEXT NOT NULL,
layer_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS repository_lock_sessions (
lock_session_id TEXT PRIMARY KEY,
base_id TEXT NOT NULL,
layer_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS repository_lock_events (
event_id TEXT PRIMARY KEY,
event TEXT NOT NULL,
occurred_at REAL NOT NULL,
base_id TEXT,
request_id TEXT,
lock_session_id TEXT,
details_json TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_repository_lock_requests_base_status "
"ON repository_lock_requests(base_id, status, created_at)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_repository_lock_sessions_base_status "
"ON repository_lock_sessions(base_id, status, created_at)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_repository_lock_events_base_time "
"ON repository_lock_events(base_id, occurred_at)"
)
conn.execute(
"""
INSERT INTO adapter_state_meta(key, value, updated_at)
VALUES('adapter_state_schema_version', ?, ?)
ON CONFLICT(key) DO UPDATE SET
value=CASE
WHEN CAST(adapter_state_meta.value AS INTEGER) < CAST(excluded.value AS INTEGER)
THEN excluded.value
ELSE adapter_state_meta.value
END,
updated_at=excluded.updated_at
""",
(str(REPOSITORY_STATE_SCHEMA_VERSION), time.time()),
)
migration = conn.execute(
"SELECT value FROM adapter_state_meta WHERE key='legacy_repository_state_imported'"
).fetchone()
if not migration:
legacy = _read_legacy_state()
_sync_state_to_connection(conn, legacy)
conn.execute(
"INSERT INTO adapter_state_meta(key, value, updated_at) VALUES(?, ?, ?)",
("legacy_repository_state_imported", "1", time.time()),
)
conn.commit()
return conn
def _event_id(row: dict[str, Any], index: int) -> str:
explicit = str(row.get("event_id") or "").strip()
if explicit:
return explicit
source = json.dumps(
{
"index": index,
"event": row.get("event"),
"time": row.get("time"),
"request_id": row.get("request_id"),
"lock_session_id": row.get("lock_session_id"),
"base_id": row.get("base_id"),
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return "legacy-" + hashlib.sha1(source.encode("utf-8")).hexdigest()
def _load_state_from_connection(conn: sqlite3.Connection) -> dict[str, Any]:
state = _empty_state()
for row in conn.execute("SELECT request_id, payload_json FROM repository_lock_requests"):
try:
payload = json.loads(row["payload_json"])
except (TypeError, json.JSONDecodeError):
continue
if isinstance(payload, dict):
state["requests"][str(row["request_id"])] = payload
for row in conn.execute("SELECT lock_session_id, payload_json FROM repository_lock_sessions"):
try:
payload = json.loads(row["payload_json"])
except (TypeError, json.JSONDecodeError):
continue
if isinstance(payload, dict):
state["sessions"][str(row["lock_session_id"])] = payload
for row in conn.execute(
"SELECT event_id, event, occurred_at, details_json FROM repository_lock_events "
"ORDER BY occurred_at, event_id"
):
try:
payload = json.loads(row["details_json"])
except (TypeError, json.JSONDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
payload.setdefault("event_id", str(row["event_id"]))
payload.setdefault("event", str(row["event"]))
payload.setdefault("time", float(row["occurred_at"]))
state["audit"].append(payload)
return state
def _sync_state_to_connection(conn: sqlite3.Connection, value: dict[str, Any]) -> None:
now = time.time()
for request_id, raw in (value.get("requests") or {}).items():
if not isinstance(raw, dict):
continue
request = dict(raw)
created_at = float(request.get("created_at") or now)
updated_at = float(
request.get("cancelled_at")
or request.get("confirmed_at")
or request.get("closed_at")
or request.get("expired_at")
or created_at
)
conn.execute(
"""
INSERT INTO repository_lock_requests(
request_id, base_id, layer_id, status, created_at, updated_at, payload_json
) VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id) DO UPDATE SET
base_id=excluded.base_id,
layer_id=excluded.layer_id,
status=excluded.status,
updated_at=excluded.updated_at,
payload_json=excluded.payload_json
""",
(
str(request_id),
str(request.get("base_id") or ""),
str(request.get("layer_id") or request.get("layer") or "base"),
str(request.get("status") or "unknown"),
created_at,
updated_at,
json.dumps(request, ensure_ascii=False, separators=(",", ":")),
),
)
for session_id, raw in (value.get("sessions") or {}).items():
if not isinstance(raw, dict):
continue
session = dict(raw)
created_at = float(session.get("created_at") or now)
updated_at = float(
session.get("committed_at")
or session.get("released_at")
or session.get("closed_at")
or session.get("expired_at")
or created_at
)
conn.execute(
"""
INSERT INTO repository_lock_sessions(
lock_session_id, base_id, layer_id, status, created_at, updated_at, payload_json
) VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(lock_session_id) DO UPDATE SET
base_id=excluded.base_id,
layer_id=excluded.layer_id,
status=excluded.status,
updated_at=excluded.updated_at,
payload_json=excluded.payload_json
""",
(
str(session_id),
str(session.get("base_id") or ""),
str(session.get("layer_id") or session.get("layer") or "base"),
str(session.get("status") or "unknown"),
created_at,
updated_at,
json.dumps(session, ensure_ascii=False, separators=(",", ":")),
),
)
audit = [row for row in (value.get("audit") or []) if isinstance(row, dict)][-5000:]
for index, raw in enumerate(audit):
event = dict(raw)
event_id = _event_id(event, index)
event["event_id"] = event_id
occurred_at = float(event.get("time") or now)
conn.execute(
"""
INSERT OR IGNORE INTO repository_lock_events(
event_id, event, occurred_at, base_id, request_id, lock_session_id, details_json
) VALUES(?, ?, ?, ?, ?, ?, ?)
""",
(
event_id,
str(event.get("event") or "unknown"),
occurred_at,
str(event.get("base_id") or "") or None,
str(event.get("request_id") or "") or None,
str(event.get("lock_session_id") or "") or None,
json.dumps(event, ensure_ascii=False, separators=(",", ":")),
),
)
def _expire_state(state: dict[str, Any]) -> bool:
changed = False
now = time.time()
request_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_REQUEST_TTL_SECONDS") or 86400))
session_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS") or 7200))
@@ -563,24 +973,57 @@ def _read_state() -> dict[str, Any]:
if isinstance(request, dict) and request.get("status") == "pending_user_lock" and now - float(request.get("created_at") if request.get("created_at") is not None else now) > request_ttl:
request["status"] = "expired"
request["expired_at"] = now
changed = True
for session in (state.get("sessions") or {}).values():
if isinstance(session, dict) and session.get("status") == "manual_confirmed" and now - float(session.get("created_at") if session.get("created_at") is not None else now) > session_ttl:
session["status"] = "expired"
session["expired_at"] = now
changed = True
return changed
def _read_state() -> dict[str, Any]:
with _STATE_LOCK:
with _state_connection() as conn:
state = _load_state_from_connection(conn)
if _expire_state(state):
conn.execute("BEGIN IMMEDIATE")
_sync_state_to_connection(conn, state)
conn.commit()
return state
def _write_state(value: dict[str, Any]) -> None:
path = _state_path()
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + f".{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(temporary, path)
with _STATE_LOCK:
with _state_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
_sync_state_to_connection(conn, value)
conn.commit()
@contextmanager
def _state_transaction() -> Any:
"""Serialize a repository state mutation across adapter processes."""
with _STATE_LOCK:
conn = _state_connection()
try:
conn.execute("BEGIN IMMEDIATE")
state = _load_state_from_connection(conn)
_expire_state(state)
yield state
_sync_state_to_connection(conn, state)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _audit(state: dict[str, Any], event: str, **details: Any) -> None:
rows = state.setdefault("audit", [])
rows.append({"event": event, "time": time.time(), **details})
rows.append({"event_id": "revt-" + uuid.uuid4().hex, "event": event, "time": time.time(), **details})
if len(rows) > 5000:
del rows[:-5000]
@@ -659,10 +1102,9 @@ def lock(payload: dict[str, Any]) -> dict[str, Any]:
if executed.get("status") != "ok":
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "repository_lock_failed", "plan": plan, "execution": executed}
session_id = "rlock-" + uuid.uuid4().hex
state = _read_state()
sessions = state.setdefault("sessions", {})
sessions[session_id] = {"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
_write_state(state)
with _state_transaction() as state:
sessions = state.setdefault("sessions", {})
sessions[session_id] = {"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "acquired", "lock_session_id": session_id, "acquired": plan["lock_objects"], "backend": config.get("backend"), "execution": executed}
@@ -724,19 +1166,33 @@ def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
if expected_repository_user and confirmed_repository_user.casefold() != expected_repository_user.casefold():
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "repository_user_mismatch", "expected_repository_user": expected_repository_user}
session_id = "rlock-" + uuid.uuid4().hex
state.setdefault("sessions", {})[session_id] = {
"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"),
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
"verification": "user_confirmation_only", "automatically_verified": False,
"repository_user": confirmed_repository_user,
**({"request_id": request_id} if request_id else {}),
}
if isinstance(request, dict):
request["status"] = "confirmed_by_user"
request["confirmed_at"] = time.time()
request["lock_session_id"] = session_id
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
_write_state(state)
with _state_transaction() as current_state:
current_request = (current_state.get("requests") or {}).get(request_id) if request_id else None
if request_id and (
not isinstance(current_request, dict)
or current_request.get("base_id") != base_id
or current_request.get("status") != "pending_user_lock"
):
return {
"schema": "onec_repository_manual_lock.v1",
"method": METHOD_CONFIRM,
"base_id": base_id,
"status": "blocked",
"error": "lock_request_not_pending",
"request_id": request_id,
}
current_state.setdefault("sessions", {})[session_id] = {
"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"),
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
"verification": "user_confirmation_only", "automatically_verified": False,
"repository_user": confirmed_repository_user,
**({"request_id": request_id} if request_id else {}),
}
if isinstance(current_request, dict):
current_request["status"] = "confirmed_by_user"
current_request["confirmed_at"] = time.time()
current_request["lock_session_id"] = session_id
_audit(current_state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
return {
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
"status": "manual_confirmed", "layer_id": layer_id, "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
@@ -769,8 +1225,7 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("lock_session_id") or "").strip()
if payload.get("user_confirmed_released") is not True:
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "confirmation_required", "lock_session_id": session_id}
with _STATE_LOCK:
state = _read_state()
with _state_transaction() as state:
session = (state.get("sessions") or {}).get(session_id)
if not isinstance(session, dict):
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "not_found", "lock_session_id": session_id}
@@ -797,7 +1252,6 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
request["closed_at"] = closed_at
if not already_closed:
_audit(state, "manual_lock_closed", request_id=request_id or None, lock_session_id=session_id, base_id=session.get("base_id"), objects=session.get("objects"))
_write_state(state)
return {
"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "closed",
"lock_session_id": session_id, "request_id": request_id or None,
@@ -882,6 +1336,18 @@ def support_gate(payload: dict[str, Any]) -> dict[str, Any]:
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
layer = layers.get(layer_id)
if not isinstance(layer, dict):
# See repository_config(): an explicit repository-less base is the
# disposable-test profile. It applies to newly discovered extensions
# as well, so a missing per-extension policy cannot turn a permitted
# test write into a false "unknown support" block.
repository, repository_error = repository_config(base_id, layer_id)
if repository_error is None and isinstance(repository, dict) and repository.get("mode") == "none":
return {
"required": False, "allowed": True,
"status": "not_on_support_inherited_no_repository",
"layer_id": layer_id,
"inherited_from_layer": repository.get("inherited_from_layer"),
}
return {"required": True, "allowed": False, "status": "blocked_support_layer_unknown", "layer_id": layer_id}
support = layer.get("support")
if not isinstance(support, dict):
@@ -952,11 +1418,16 @@ def commit(payload: dict[str, Any]) -> dict[str, Any]:
executed = _execute_repository(base_id, config, "commit", int(payload.get("timeout_seconds") or 180), objects=[str(item) for item in session.get("objects") or []], comment=str(plan["comment"]), keep_locked=payload.get("keep_locked") is True)
if executed.get("status") != "ok":
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "repository_commit_failed", "lock_session_id": session_id, "execution": executed}
session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
session["committed_at"] = time.time()
session["commit_comment"] = str(plan["comment"])
_write_state(state)
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": session["status"], "lock_session_id": session_id, "committed": session.get("objects"), "keep_locked": payload.get("keep_locked") is True, "execution": executed}
with _state_transaction() as current_state:
current_session = (current_state.get("sessions") or {}).get(session_id)
if not isinstance(current_session, dict) or current_session.get("status") != "acquired":
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id}
current_session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
current_session["committed_at"] = time.time()
current_session["commit_comment"] = str(plan["comment"])
final_status = str(current_session["status"])
committed_objects = current_session.get("objects")
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": final_status, "lock_session_id": session_id, "committed": committed_objects, "keep_locked": payload.get("keep_locked") is True, "execution": executed}
def unlock(payload: dict[str, Any]) -> dict[str, Any]:
@@ -977,10 +1448,14 @@ def unlock(payload: dict[str, Any]) -> dict[str, Any]:
executed = _execute_repository(base_id, config, "unlock", int(payload.get("timeout_seconds") or 120), objects=[str(item) for item in session.get("objects") or []])
if executed.get("status") != "ok":
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "repository_unlock_failed", "lock_session_id": session_id, "execution": executed}
session["status"] = "released"
session["released_at"] = time.time()
_write_state(state)
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": session.get("objects"), "execution": executed}
with _state_transaction() as current_state:
current_session = (current_state.get("sessions") or {}).get(session_id)
if not isinstance(current_session, dict) or current_session.get("status") != "acquired":
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id}
current_session["status"] = "released"
current_session["released_at"] = time.time()
released_objects = current_session.get("objects")
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": released_objects, "execution": executed}
def call(method: str, payload: dict[str, Any]) -> dict[str, Any]:
+1
View File
@@ -0,0 +1 @@
"""Typed write-dispatch contracts for the SQL-only 1C adapter."""
+20
View File
@@ -0,0 +1,20 @@
"""Explicit adapter services available to typed write handlers."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class AdapterWriteContext:
"""Migration boundary: handlers receive services, never server globals.
`legacy_scheduled_job_writer` is temporary while the existing proven
implementation is characterized. It prevents the dispatcher from keeping
a direct dependency on that writer and is replaced by granular services
when the implementation body moves into the handler.
"""
legacy_scheduled_job_writer: Callable[[dict[str, Any]], dict[str, Any]]
+14
View File
@@ -0,0 +1,14 @@
"""Stable contracts shared by the universal dispatcher and typed handlers."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class WriteHandler:
"""A supported public write surface, not an SQL implementation detail."""
key: str
public_target_kind: str
operation: str | None = None
@@ -0,0 +1,5 @@
"""Typed write-handler declarations.
Implementations are migrated here one at a time after their existing adapter
tests become handler-level characterization tests.
"""
@@ -0,0 +1,5 @@
from write.contracts import WriteHandler
# Element, command/button, and embedded-module routing needs decoded form
# evidence, so it remains a sub-dispatch inside this public form surface.
HANDLER = WriteHandler("form", "form")
@@ -0,0 +1,3 @@
from write.contracts import WriteHandler
HANDLER = WriteHandler("module", "module")
@@ -0,0 +1,3 @@
from write.contracts import WriteHandler
HANDLER = WriteHandler("object_member", "object", "add_attribute")
@@ -0,0 +1,3 @@
from write.contracts import WriteHandler
HANDLER = WriteHandler("object_property", "object")
@@ -0,0 +1,11 @@
from typing import Any
from write.context import AdapterWriteContext
from write.contracts import WriteHandler
HANDLER = WriteHandler("scheduled_job_schedule", "schedule")
def execute(payload: dict[str, Any], context: AdapterWriteContext) -> dict[str, Any]:
"""Run the current proven scheduled-job writer through the handler seam."""
return context.legacy_scheduled_job_writer(payload)
+45
View File
@@ -0,0 +1,45 @@
"""Pure, storage-free selection of a typed configuration write handler.
The registry deliberately contains no SQL, payload, or 1C metadata decoding.
It is the first migration seam out of the monolithic adapter server.
"""
from __future__ import annotations
from write.contracts import WriteHandler
from write.handlers.form import HANDLER as FORM_HANDLER
from write.handlers.module import HANDLER as MODULE_HANDLER
from write.handlers.object_member import HANDLER as OBJECT_MEMBER_HANDLER
from write.handlers.object_property import HANDLER as OBJECT_PROPERTY_HANDLER
from write.handlers.scheduled_job import HANDLER as SCHEDULE_HANDLER
def select_write_handler(*, target_kind: str, operation: str = "", is_schedule: bool = False) -> WriteHandler | None:
"""Return one supported typed handler or ``None`` for a forbidden target.
Detailed form sub-routing (element, command, embedded module) remains in
the form handler. It needs decoded target evidence that is unavailable at
this pure public-intent stage.
"""
if is_schedule:
return SCHEDULE_HANDLER
normalized_kind = str(target_kind or "").strip().casefold()
normalized_operation = str(operation or "").strip().casefold()
if normalized_kind in {"object", "объект", "metadata", "метаданные"}:
if normalized_operation in {"add_attribute", "attribute_add", "добавить_реквизит", "добавитьреквизит"}:
return OBJECT_MEMBER_HANDLER
return OBJECT_PROPERTY_HANDLER
if normalized_kind in {"module", "модуль", "bsl"}:
return MODULE_HANDLER
if normalized_kind in {"form", "форма"}:
return FORM_HANDLER
return None
def registered_handlers() -> list[dict[str, str | None]]:
"""Public-safe registry summary; contains no SQL implementation details."""
handlers = [MODULE_HANDLER, FORM_HANDLER, OBJECT_PROPERTY_HANDLER, OBJECT_MEMBER_HANDLER, SCHEDULE_HANDLER]
return [
{"key": handler.key, "target_kind": handler.public_target_kind, "operation": handler.operation}
for handler in handlers
]