Initial project import
This commit is contained in:
@@ -33,8 +33,15 @@ ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite
|
||||
ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite
|
||||
# Legacy JSON job store is read once for migration only.
|
||||
ONEC_ADAPTER_JOB_STORE=/data/adapter-jobs.json
|
||||
# Repository requests, confirmations, sessions, and audit are stored in
|
||||
# ONEC_ADAPTER_STATE_DB. This legacy JSON is imported once and never updated.
|
||||
ONEC_REPOSITORY_STATE_FILE=/data/onec-repository-locks.json
|
||||
ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups
|
||||
ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning
|
||||
# Activation requests/events are stored in ONEC_ADAPTER_STATE_DB.
|
||||
# Legacy JSON is read once for migration only and is never updated afterwards.
|
||||
ONEC_CONFIGURATION_ACTIVATION_STATE_FILE=/data/onec-configuration-activation-requests.json
|
||||
ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS=1800
|
||||
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240
|
||||
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600
|
||||
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180
|
||||
@@ -43,3 +50,7 @@ ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true
|
||||
# Optional POSIX child-process limits; 0 keeps the platform/container limit.
|
||||
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0
|
||||
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0
|
||||
# Stack traces are hidden from REST/MCP clients unless these test/debug flags
|
||||
# are explicitly enabled.
|
||||
ONEC_ADAPTER_DEBUG_DIAGNOSTICS=false
|
||||
ONEC_MCP_DEBUG_DIAGNOSTICS=false
|
||||
|
||||
@@ -3,7 +3,9 @@ FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir pymssql==2.3.2
|
||||
COPY connector/adapter_1c_server.py /app/adapter_1c_server.py
|
||||
COPY connector/analyze_audit.py /app/analyze_audit.py
|
||||
COPY connector/repository_control.py /app/repository_control.py
|
||||
COPY connector/write /app/write
|
||||
COPY connector/admin /app/admin
|
||||
COPY parser /app/parser
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ The connector is read-first and optimized for an operational coding loop where f
|
||||
|
||||
Preferred live architecture:
|
||||
|
||||
- read-only SQL connector for fast diagnostics and data samples;
|
||||
- lightweight 1C agent for metadata, forms, commands, and BSL modules;
|
||||
- SQL-only connector for diagnostics, metadata decoding, and controlled
|
||||
saved-state work in an explicitly authorised test base;
|
||||
- a human-operated Configurator for viewing and applying pending changes;
|
||||
- cached metadata/module snapshots with freshness checks;
|
||||
- change proposals as reviewable artifacts, not direct production writes.
|
||||
|
||||
@@ -17,7 +18,12 @@ The connector is responsible for:
|
||||
- BSL module search/read;
|
||||
- read-only query validation and execution;
|
||||
- metadata/module snapshots;
|
||||
- change proposals without direct apply.
|
||||
- change proposals and, only where a reverse codec is activation-proven,
|
||||
controlled `ConfigSave`/`ConfigCASSave` writes with rollback evidence.
|
||||
|
||||
The adapter never writes `Config`, `ConfigCAS`, or application data directly.
|
||||
It does not automate Configurator and must not invent unknown 1C structures.
|
||||
The protocol evidence base is [docs/1c-sql-protocol](../../../docs/1c-sql-protocol/README.md).
|
||||
|
||||
Contracts:
|
||||
|
||||
@@ -122,6 +128,57 @@ adapter-owned lock session is supplied. Structural add/delete/rename plans are
|
||||
kept blocked for confirmation because parent and reference objects can also be
|
||||
required.
|
||||
|
||||
## Configuration activation debug workflow
|
||||
|
||||
Activation is a separate boundary from saved-state writes and repository
|
||||
coordination. The current workflow is intentionally debug-only:
|
||||
|
||||
1. `configuration.activation.status`;
|
||||
2. `configuration.activation.plan`;
|
||||
3. `configuration.activation.request`;
|
||||
4. forward the returned request id to `configuration.activation.execute` with
|
||||
`mode=debug` and `confirm_activation=true`;
|
||||
5. inspect or cancel the request through
|
||||
`configuration.activation.request.status`,
|
||||
`configuration.activation.request.cancel`, and
|
||||
`configuration.activation.audit`.
|
||||
|
||||
The request is bound to a live-SQL fingerprint and is rejected when pending
|
||||
files change or the request expires. Requests and events are stored in the
|
||||
adapter-local SQLite selected by `ONEC_ADAPTER_STATE_DB`; they contain no
|
||||
payload bytes or credentials. `ONEC_CONFIGURATION_ACTIVATION_STATE_FILE` is a
|
||||
one-time legacy JSON import source only. `configuration.activation.capabilities`
|
||||
reports runner readiness without returning paths, URLs, selectors, users,
|
||||
passwords, or tokens.
|
||||
`configuration.activation.bridge.probe` can then check the local runner or the
|
||||
authenticated HTTP runner endpoint `/configuration/activation/debug`. The
|
||||
probe verifies only Designer-file availability and infobase-selector presence;
|
||||
it never starts a process.
|
||||
Pass `bridge_debug=true` to `configuration.activation.execute` when the runner
|
||||
must also acknowledge the exact request id and live-SQL fingerprint. The runner
|
||||
returns an opaque SHA-256 debug receipt; mismatched or missing receipts block
|
||||
the request, while a valid receipt adds a `bridge_debug_accepted` audit event.
|
||||
After a manual F7, call `configuration.activation.verify` with the same request
|
||||
id. It reports `not_activated`, `changed_since_request`, or
|
||||
`verified_up_to_date` from a fresh SQL comparison. The last status proves
|
||||
saved/active alignment, not the historical fact that Designer performed the
|
||||
activation.
|
||||
|
||||
Real Designer execution remains disabled. `/UpdateDBCfg` is recorded only as
|
||||
the documented future base-configuration operation. Extension activation stays
|
||||
manual until a separately verified platform command and post-activation check
|
||||
are implemented.
|
||||
|
||||
Activation request mutations use SQLite `BEGIN IMMEDIATE` transactions, so
|
||||
concurrent adapter processes cannot overwrite each other's request/event
|
||||
updates. Saved-state backup retention is explicit:
|
||||
`storage.saved_state.backups.prune` defaults to a dry run, is scoped by
|
||||
`base_id`, preserves the newest requested count, and requires
|
||||
`confirm_delete=true` before deleting adapter-local backup files. Backups
|
||||
referenced by `metadata.write.history` are always protected; when write-history
|
||||
availability cannot be verified, affected backup files are protected
|
||||
fail-closed.
|
||||
|
||||
## Docker Run
|
||||
|
||||
Create a local `.env` from `.env.example`, keep real passwords outside git, and
|
||||
@@ -237,6 +294,8 @@ Current live methods:
|
||||
- `metadata.route.resolve`
|
||||
- `metadata.form.decode`
|
||||
- `metadata.object.attributes`
|
||||
- `metadata.relationship.verify`
|
||||
- `metadata.relationship.find`
|
||||
- `metadata.object.full`
|
||||
- `metadata.snapshot`
|
||||
- `codec.decode`
|
||||
@@ -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.
|
||||
|
||||
+16271
-759
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
"""Summarize privacy-safe adapter JSONL telemetry inside the REST image."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--log", default="/data/adapter-audit.jsonl")
|
||||
parser.add_argument("--slow-ms", type=int, default=5_000)
|
||||
parser.add_argument("--limit", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
path = Path(args.log)
|
||||
rows: list[dict] = []
|
||||
malformed_rows = 0
|
||||
if path.exists():
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed_rows += 1
|
||||
continue
|
||||
if item.get("event") == "adapter_rpc":
|
||||
rows.append(item)
|
||||
by_base = Counter(str((row.get("request") or {}).get("base_id") or "<none>") for row in rows)
|
||||
exceptions = [row for row in rows if str(row.get("status") or "") == "exception" or row.get("error") == "request_exception"]
|
||||
rejected = [row for row in rows if str(row.get("status") or "") in {"blocked", "unsupported", "invalid_argument"}]
|
||||
slow = sorted((row for row in rows if int(row.get("duration_ms") or 0) >= args.slow_ms), key=lambda row: int(row.get("duration_ms") or 0), reverse=True)
|
||||
print(json.dumps({
|
||||
"schema": "onec_adapter_audit_summary.v1",
|
||||
"status": "ok" if path.exists() else "log_not_found",
|
||||
"events": len(rows), "malformed_rows": malformed_rows,
|
||||
"time_range": {"from": rows[0].get("time") if rows else None, "to": rows[-1].get("time") if rows else None},
|
||||
"bases": dict(by_base), "adapter_exceptions": len(exceptions),
|
||||
"expected_rejections": len(rejected),
|
||||
"exception_methods": dict(Counter(str(row.get("method") or "<none>") for row in exceptions).most_common(args.limit)),
|
||||
"slow_threshold_ms": args.slow_ms,
|
||||
"slow": [{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} for row in slow[:args.limit]],
|
||||
"recent_exceptions": [{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "exception_type": row.get("exception_type"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} for row in exceptions[-args.limit:]],
|
||||
"findings": [
|
||||
*([{"priority": "P1", "kind": "adapter_exception", "count": len(exceptions), "next_action": "Inspect the matching REST request_id and exception_type; reproduce only on upo_test before changing code."}] if exceptions else []),
|
||||
*([{"priority": "P2", "kind": "slow_calls", "count": len(slow), "next_action": "Inspect timings_ms for the listed methods; optimise only after a repeated pattern is confirmed."}] if slow else []),
|
||||
*([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect log rotation and container shutdown events."}] if malformed_rows else []),
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -54,6 +54,140 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
/configuration/activation/status:
|
||||
post:
|
||||
operationId: getConfigurationActivationStatus
|
||||
summary: Read the live saved-state to active boundary
|
||||
description: Compares saved-state and active configuration layers without cache, vector search, Designer execution, or configuration mutation.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequest"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/plan:
|
||||
post:
|
||||
operationId: planConfigurationActivation
|
||||
summary: Build a read-only activation handoff plan
|
||||
description: Returns review and verification calls plus a manual Designer action when activation is required. It never starts Designer.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequest"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request:
|
||||
post:
|
||||
operationId: createConfigurationActivationRequest
|
||||
summary: Create a fingerprinted activation request
|
||||
description: Persists an expiring adapter-local request bound to the exact live-SQL saved-state fingerprint. It does not start Designer.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestCreate"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request/status:
|
||||
post:
|
||||
operationId: getConfigurationActivationRequest
|
||||
summary: Read activation request state
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestStatus"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request/cancel:
|
||||
post:
|
||||
operationId: cancelConfigurationActivationRequest
|
||||
summary: Cancel one activation request
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestCancel"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/audit:
|
||||
post:
|
||||
operationId: auditConfigurationActivationRequests
|
||||
summary: List activation request lifecycle events for one base
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationAudit"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/capabilities:
|
||||
post:
|
||||
operationId: getConfigurationActivationCapabilities
|
||||
summary: Read safe Designer bridge readiness
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationCapabilities"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/bridge/probe:
|
||||
post:
|
||||
operationId: probeConfigurationActivationBridge
|
||||
summary: Probe local or HTTP Designer runner readiness without execution
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationBridgeProbe"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/execute:
|
||||
post:
|
||||
operationId: debugConfigurationActivation
|
||||
summary: Accept a fingerprinted activation request in debug mode
|
||||
description: Revalidates the exact live-SQL fingerprint and records debug acceptance. Real Designer execution is unavailable.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationExecute"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/verify:
|
||||
post:
|
||||
operationId: verifyConfigurationActivation
|
||||
summary: Verify saved/active alignment for one activation request
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationVerify"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/extensions:
|
||||
get:
|
||||
operationId: listExtensions
|
||||
@@ -258,7 +392,7 @@ paths:
|
||||
type: string
|
||||
enum: [Params, Config, ConfigSave, ConfigCAS, ConfigCASSave]
|
||||
default: Config
|
||||
description: Storage table to read from. Use Config for active config, ConfigSave for saved config.
|
||||
description: Storage table to read from. Exact public extension selectors resolve to ConfigCAS internally; physical routes remain hidden unless include_storage=true.
|
||||
guid:
|
||||
type: string
|
||||
description: Config object GUID. If omitted, kind and name are used.
|
||||
@@ -451,7 +585,7 @@ paths:
|
||||
type: string
|
||||
enum: [Params, Config, ConfigSave, ConfigCAS, ConfigCASSave]
|
||||
default: Config
|
||||
description: Storage table to read from. Use Config for active config, ConfigSave for saved config.
|
||||
description: Storage table to read from. Exact public extension selectors resolve to ConfigCAS internally; physical routes remain hidden unless include_storage=true.
|
||||
guid:
|
||||
type: string
|
||||
description: Config object GUID. If omitted, kind and name are used.
|
||||
@@ -461,7 +595,7 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Live BSL module stream ids for a metadata object.
|
||||
description: Public BSL modules owned by the selected object, including owned form modules. Exact extension objects are resolved from public kind/name or ref.
|
||||
/metadata/object/related:
|
||||
post:
|
||||
operationId: listMetadataObjectRelated
|
||||
@@ -958,6 +1092,11 @@ paths:
|
||||
ref:
|
||||
type: string
|
||||
description: Public object reference such as Справочник.Номенклатура.
|
||||
state:
|
||||
type: string
|
||||
enum: [working, active, save, both]
|
||||
default: working
|
||||
description: Working is saved-first with active fallback, including form modules owned by extension objects resolved from the public selector.
|
||||
responses:
|
||||
"200":
|
||||
description: Read-only routine override/action chain across base and extension modules.
|
||||
@@ -983,6 +1122,9 @@ paths:
|
||||
source:
|
||||
type: string
|
||||
enum: [configuration, extension]
|
||||
activation_state:
|
||||
type: string
|
||||
enum: [active, saved_state]
|
||||
method:
|
||||
type: string
|
||||
line_start:
|
||||
@@ -998,7 +1140,7 @@ paths:
|
||||
enum: [ok, unknown]
|
||||
operation_class:
|
||||
type: string
|
||||
description: base_definition, insert_before, insert_after, replace, replace_with_control, or unknown_extension_action.
|
||||
description: base_definition, extension_definition, insert_before, insert_after, replace, replace_with_control, or unknown_extension_action.
|
||||
requires_control_fragment:
|
||||
type: boolean
|
||||
extension_actions:
|
||||
@@ -1208,6 +1350,20 @@ paths:
|
||||
module_ref:
|
||||
type: string
|
||||
description: Opaque module_ref from code.search/modules.search read_selector when already known.
|
||||
expected_sha1:
|
||||
type: string
|
||||
description: Optional container SHA1 precondition, normally forwarded from metadata.write.preflight write_context.
|
||||
expected_text_sha1:
|
||||
type: string
|
||||
description: Optional BSL text SHA1 precondition, normally forwarded from metadata.write.preflight write_context.
|
||||
repository_lock:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Forwardable repository.lock.confirm write_context.
|
||||
write_context:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Forwardable context returned by metadata.write.preflight.
|
||||
routine_name:
|
||||
type: string
|
||||
routine_text:
|
||||
@@ -1483,6 +1639,26 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: routine_name
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Select one BSL procedure/function. Its text is compact by default.
|
||||
- name: include_routines
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Include the routine catalogue when a routine is selected.
|
||||
- name: include_summary
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Include the module summary when a routine is selected.
|
||||
responses:
|
||||
"200":
|
||||
description: BSL module content.
|
||||
@@ -1725,6 +1901,51 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: Saved-state apply backups list with source metadata and sha1/byte counts. Payload hex is not returned.
|
||||
/storage/saved-state/backups/prune:
|
||||
post:
|
||||
operationId: pruneSavedStateBackups
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [base_id]
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
table:
|
||||
type: string
|
||||
enum: [ConfigSave, ConfigCASSave]
|
||||
file_name:
|
||||
type: string
|
||||
older_than_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 3650
|
||||
default: 30
|
||||
keep_latest:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 10000
|
||||
default: 20
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10000
|
||||
default: 500
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: true
|
||||
confirm_delete:
|
||||
type: boolean
|
||||
default: false
|
||||
diagnostic:
|
||||
type: boolean
|
||||
description: Required when called through the generic MCP diagnostic policy.
|
||||
responses:
|
||||
"200":
|
||||
description: Dry-run selection or confirmed deletion of adapter-local saved-state backup files. Backups referenced by write history are always protected, with fail-closed protection when history cannot be verified.
|
||||
/access/graph:
|
||||
post:
|
||||
operationId: buildAccessGraph
|
||||
@@ -2246,6 +2467,156 @@ components:
|
||||
type: http
|
||||
scheme: bearer
|
||||
schemas:
|
||||
ConfigurationActivationRequest:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
default: 5000
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
include_files:
|
||||
type: boolean
|
||||
default: false
|
||||
include_storage:
|
||||
type: boolean
|
||||
default: false
|
||||
ConfigurationActivationRequestCreate:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
default: 5000
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
ttl_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 86400
|
||||
default: 1800
|
||||
ConfigurationActivationRequestStatus:
|
||||
type: object
|
||||
required: [request_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
ConfigurationActivationRequestCancel:
|
||||
type: object
|
||||
required: [request_id, confirm_cancel]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
confirm_cancel:
|
||||
type: boolean
|
||||
const: true
|
||||
ConfigurationActivationAudit:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
status:
|
||||
type: string
|
||||
ConfigurationActivationCapabilities:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
ConfigurationActivationBridgeProbe:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 60
|
||||
default: 10
|
||||
ConfigurationActivationExecute:
|
||||
type: object
|
||||
required: [base_id, request_id, confirm_activation]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
mode:
|
||||
type: string
|
||||
const: debug
|
||||
default: debug
|
||||
confirm_activation:
|
||||
type: boolean
|
||||
const: true
|
||||
bridge_debug:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Require an end-to-end local/HTTP runner debug receipt without starting Designer.
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
ConfigurationActivationVerify:
|
||||
type: object
|
||||
required: [base_id, request_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
AdapterRpcRequest:
|
||||
type: object
|
||||
required: [method]
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Исследования SQL: активность расширений
|
||||
|
||||
Статус: `in_progress`. Этот журнал отделяет наблюдения от гипотез. Ничего из
|
||||
раздела «гипотеза» не используется адаптером как runtime-правило.
|
||||
|
||||
## 2026-08-02 — baseline `upo_test`
|
||||
|
||||
Цель: установить доказанный SQL-признак флажка «Активно» расширения в
|
||||
Конфигураторе.
|
||||
|
||||
Снимок выполнен только чтением из `dbo._ExtensionsInfo` для имён
|
||||
`фс_Отчеты` и `фс_Отчеты1`. Зафиксированы все известные скалярные поля строки
|
||||
и SHA1 `_ExtensionZippedInfo`; бинарные данные и учётные сведения не сохранены.
|
||||
|
||||
| name | `_IDRRef` (hex) | `_ExtensionOrder` | `_UpdateTime` | `_ExtensionUsePurpose` | `_ExtensionScope` | zipped bytes | zipped SHA1 |
|
||||
| --- | --- | ---: | --- | ---: | ---: | ---: | --- |
|
||||
| `фс_Отчеты` | `0x8287005056B0D48311F13D089B11F844` | 21 | 4026-05-07 23:45:25 | 2 | 1 | 188 | `0D07CB6CE05AADB301A002CAB7312AD6EA64A344` |
|
||||
| `фс_Отчеты1` | *строка отсутствует* | — | — | — | — | — | — |
|
||||
|
||||
### Подтверждено
|
||||
|
||||
- Наличие строки в `_ExtensionsInfo` доказывает регистрацию расширения в
|
||||
данном SQL-снимке, но **не** доказывает флажок «Активно» в Конфигураторе.
|
||||
- Поэтому поле `active` адаптера для такого источника возвращается как
|
||||
`null`; прежнее значение `true` было неподтверждённым и удалено.
|
||||
|
||||
### Гипотеза, требующая проверки
|
||||
|
||||
После переключения флажков в Конфигураторе изменится одна или несколько
|
||||
наблюдаемых SQL-структур: строка `_ExtensionsInfo`, поля строки, DBNames-Ext,
|
||||
ConfigCAS/ConfigCASSave или иной live SQL-маркер.
|
||||
|
||||
### Следующий контролируемый опыт
|
||||
|
||||
1. Пользователь активирует `фс_Отчеты` и выключает `фс_Отчеты1` (либо наоборот)
|
||||
в Конфигураторе и сообщает, когда действие сохранено.
|
||||
2. Адаптер снимает тот же снимок `_ExtensionsInfo` и дополнительно сравнивает
|
||||
только подтверждённые live SQL-маркеры.
|
||||
3. Правило будет добавлено в runtime лишь если различие воспроизводится при
|
||||
обратном переключении и однозначно связано с активной композицией.
|
||||
|
||||
### Запрещённый вывод до опыта
|
||||
|
||||
Нельзя отбрасывать расширение из поиска только по факту его наличия или
|
||||
отсутствия в `_ExtensionsInfo`, по совпадающему GUID объекта либо по догадке
|
||||
из названия/порядка расширения.
|
||||
|
||||
## 2026-08-02 — baseline `upo` (текущий опыт)
|
||||
|
||||
Этот снимок является исходной точкой для переключения, которое пользователь
|
||||
будет выполнять в `upo`. Он не смешивается с наблюдением `upo_test` выше.
|
||||
|
||||
| name | `_IDRRef` (hex) | GUID из `_IDRRef` | `_ExtensionOrder` | `_UpdateTime` | `_ExtensionUsePurpose` | `_ExtensionScope` | zipped bytes | zipped SHA1 |
|
||||
| --- | --- | --- | ---: | --- | ---: | ---: | ---: | --- |
|
||||
| `фс_Отчеты` | `0xA0CF005056B59ABC11F13D592F0651F1` | `2f0651f1-3d59-11f1-a0cf-005056b59abc` | 20 | 2001-01-01 00:00:00 | 2 | 1 | 188 | `2718A3CC564F593799EFCF1E716305358AF804E8` |
|
||||
| `фс_Отчеты1` | `0x8294005056B0D48311F18A348E02ACCD` | `8e02accd-8a34-11f1-8294-005056b0d483` | 22 | 4026-08-01 18:51:55 | 2 | 1 | 188 | `A9A4AF42BBA2084141A122F9D7C39D7E1428CB21` |
|
||||
|
||||
На baseline присутствуют обе строки. Значит наличие в `_ExtensionsInfo` не
|
||||
может быть критерием активности: оно не отличает выключенное `фс_Отчеты` от
|
||||
включенного `фс_Отчеты1` на скриншоте пользователя.
|
||||
|
||||
## 2026-08-02 — подтверждённый декодер активности
|
||||
|
||||
Три независимых переключения флажка в Конфигураторе, сохранённые пользователем
|
||||
в `upo`, дали один и тот же результат. Не весь SHA1, а **третий байт с конца**
|
||||
`_ExtensionZippedInfo` меняется вместе с флажком:
|
||||
|
||||
| расширение | длина контейнера | состояние в UI | третий байт с конца |
|
||||
| --- | ---: | --- | --- |
|
||||
| `фс_Отчеты` | 188 | выключено → включено | `81` → `82` |
|
||||
| `фс_Отчеты1` | 188 | включено → выключено | `82` → `81` |
|
||||
| `ЭкстракторДанных1СВBI` | 215 | включено → выключено | `82` → `81` |
|
||||
|
||||
Другие изменения контейнера не являются маркером: например, байт около начала
|
||||
контейнера и весь SHA1 меняются при сохранении Конфигуратором.
|
||||
|
||||
### Runtime-правило (подтверждено для наблюдаемой версии)
|
||||
|
||||
`SUBSTRING(_ExtensionZippedInfo, DATALENGTH(_ExtensionZippedInfo) - 2, 1)`:
|
||||
|
||||
- `0x82` — расширение активно;
|
||||
- `0x81` — расширение выключено;
|
||||
- любое иное значение — `active: null`, `unresolved`.
|
||||
|
||||
Правило декодирует только активность и не интерпретирует остальные байты
|
||||
контейнера. Перед использованием для фильтрации глобального поиска требуется
|
||||
отдельный regression-тест, что выключенная extension route не попадает в
|
||||
`effective_working` code search/read.
|
||||
|
||||
## 2026-08-02 — расширенная матрица флажков
|
||||
|
||||
В Конфигураторе была показана полная таблица расширений с дополнительными
|
||||
флажками: безопасный режим, защита от опасных действий, использование в
|
||||
распределённой ИБ и «использовать основной режим». Их комбинации различаются
|
||||
между активными расширениями. Повторный read-only снимок `upo` дал:
|
||||
|
||||
- 14 расширений с UI-флажком «Активно» получили завершающий байт `82`;
|
||||
- `ЭкстракторДанных1СВBI` и `фс_Отчеты1` с выключенным «Активно» получили
|
||||
`81`;
|
||||
- среди активных строк есть разные состояния каждого показанного
|
||||
дополнительного флажка, но их завершающий байт всё равно `82`.
|
||||
|
||||
### Уточнённый вывод
|
||||
|
||||
`81` и `82` надо рассматривать как два **наблюдаемых кода состояния
|
||||
активности** в третьем байте с конца, а не как полную структуру битовых
|
||||
флажков расширения. Технически это может быть битовое поле, перечисление или
|
||||
маркер внутри более крупного протокола — формат этого байта пока не доказан.
|
||||
Для runtime достаточно точного соответствия `81`/`82`; никаких выводов о
|
||||
других флажках из него делать нельзя.
|
||||
|
||||
### Принятое правило адаптера
|
||||
|
||||
Рабочая композиция адаптера содержит только строки с `active: true` (`82`).
|
||||
По умолчанию неактивные и нераспознанные расширения:
|
||||
|
||||
- не выдаются методом `extensions.list`;
|
||||
- не участвуют в DBNames-Ext, ConfigCAS, manifest и cache-маршрутах;
|
||||
- не участвуют в глобальном поиске модулей и объектов;
|
||||
- не могут стать целью чтения или записи.
|
||||
|
||||
Явная попытка обратиться к известному выключенному расширению завершается
|
||||
`status: unavailable`, `error: extension_inactive`; адаптер не читает и не
|
||||
строит маршрут к его объектам. Это исключает неоднозначность одинакового GUID
|
||||
объекта в активном и выключенном расширениях.
|
||||
|
||||
Это правило распространяется и на технические селекторы: верхнеуровневый
|
||||
`extension_guid`, а также публичный `ConfigCASSave` file route с префиксом
|
||||
GUID. Публичное чтение `ConfigCAS` разрешено только для ключа, который
|
||||
подтверждён манифестом активного расширения; непринадлежащий активной
|
||||
композиции файл получает `extension_route_not_active`. Внутренние SQL-вызовы
|
||||
адаптера отделены от этого публичного барьера, чтобы он мог доказуемо
|
||||
построить маршрут, но не раскрывает эти строки агенту.
|
||||
|
||||
### Неподтверждённая гипотеза
|
||||
|
||||
Остальные флажки записаны в других позициях `_ExtensionZippedInfo` либо в
|
||||
другой SQL-структуре. Это не используется адаптером.
|
||||
|
||||
### Следующий опыт для декодирования остальных флажков (только по необходимости)
|
||||
|
||||
На одном выбранном расширении оставить «Активно» неизменным и переключить
|
||||
ровно один другой флажок, сохранить, затем снять бинарный diff. Повторить
|
||||
обратное переключение. До двухстороннего воспроизведения позиция и смысл
|
||||
изменившихся байтов остаются гипотезой.
|
||||
|
||||
## Неподтверждённое направление — opaque `module_ref`
|
||||
|
||||
Нельзя отбрасывать любой `ConfigCAS`/`ConfigCASSave` `module_ref` только по
|
||||
имени физического файла: у части подтверждённых активных модулей GUID
|
||||
расширения отсутствует в имени и восстанавливается только из доказанного
|
||||
контекста владельца. Ранняя фильтрация такого `module_ref` была проверена и
|
||||
отменена, так как блокировала активные маршруты. Дальнейшее усиление возможно
|
||||
только после доказанного owner-resolution до чтения модуля; до этого нельзя
|
||||
объявлять opaque module_ref маршрутом выключенного расширения или менять его
|
||||
семантику догадкой.
|
||||
|
||||
### Подтверждённое частное правило для `module_ref`
|
||||
|
||||
Если physical `ConfigCASSave module_ref` содержит стандартный префикс
|
||||
`<extension-guid>__`, GUID слоя доказуем до чтения контейнера. Адаптер
|
||||
проверяет этот GUID по активной композиции и возвращает `extension_inactive`
|
||||
для выключенного расширения. Это правило не распространяется на непрозрачные
|
||||
имена файлов без GUID: для них по-прежнему требуется доказательство владельца.
|
||||
@@ -1,6 +1,6 @@
|
||||
id: 1c-designer-sql-decoding-policy
|
||||
status: active
|
||||
summary: "Controlled changes in a disposable 1C base may be made only through 1C clients; the adapter observes and decodes SQL without writing application data."
|
||||
summary: "The adapter is a SQL codec only: it decodes and encodes strictly by the live-SQL-derived, versioned configuration-storage specification. In an explicitly authorised test base it may write only verified configuration saved-state overlays. It never invents 1C structure, BSL, or integrity atoms, and never writes active configuration or application data."
|
||||
|
||||
scope:
|
||||
default_base_id: upo_test
|
||||
@@ -8,8 +8,10 @@ scope:
|
||||
forbidden_base_class: [production, unclassified]
|
||||
platform_mutation_authority:
|
||||
application_data: 1c_enterprise_client
|
||||
metadata_working_state: 1c_designer
|
||||
adapter_role: sql_observer_and_decoder
|
||||
active_configuration: 1c_designer
|
||||
metadata_saved_state: adapter_sql_only_with_verified_codec
|
||||
adapter_role: specification_bound_sql_decoder_and_controlled_saved_state_writer
|
||||
fundamental_rule: "Unknown, incomplete, or ambiguous structure returns explicit evidence and unsupported/partial/ambiguous; no guessed decoding or encoding is permitted."
|
||||
|
||||
credentials:
|
||||
persistence: forbidden_in_repository
|
||||
@@ -35,7 +37,7 @@ experiment:
|
||||
forbidden_selectors_for_callers: [sql_number, physical_table, internal_guid_only]
|
||||
|
||||
sql_observation:
|
||||
adapter_access: read_only
|
||||
adapter_access: sql_only
|
||||
allowed: [SELECT, metadata_schema_inspection, ConfigSave_read, ConfigCASSave_read, application_table_read]
|
||||
forbidden:
|
||||
- direct_application_data_write
|
||||
@@ -43,7 +45,20 @@ sql_observation:
|
||||
- direct_ConfigCAS_write
|
||||
- sql_identity_or_permission_change
|
||||
- trigger_or_profiler_installation
|
||||
rule: "All experimental mutations happen through 1C; SQL is evidence, not the mutation transport."
|
||||
rule: "For production and unclassified bases SQL is evidence only. In the explicitly authorised disposable base, SQL writes are limited to ConfigSave/ConfigCASSave after a proven lossless codec, exact preconditions, atomic paired-file update, backup, and readback verification."
|
||||
|
||||
saved_state_write:
|
||||
allowed_base_id: upo_test
|
||||
allowed_tables: [ConfigSave, ConfigCASSave]
|
||||
forbidden_tables: [Config, ConfigCAS]
|
||||
required:
|
||||
- "Resolve the target by live public-name evidence; do not require callers to supply a physical selector."
|
||||
- "Read and hash every target byte stream before writing."
|
||||
- "Use an exact, unique edit anchor or a proven offset/path selector; otherwise return an ambiguity error."
|
||||
- "For extension saved-state, update the changed payload and the matching __configinfo file-SHA1 reference atomically."
|
||||
- "Preserve unproven service atoms byte-for-byte; never generate a value by guesswork or randomness."
|
||||
- "Create rollback evidence and verify SQL readback after commit."
|
||||
- "Return Configurator refresh guidance based on whether the object existed in saved-state before the write."
|
||||
|
||||
metadata_layers:
|
||||
designer_save:
|
||||
@@ -66,4 +81,3 @@ promotion_gates:
|
||||
- "The rule is reproduced with a second value or a second object of the same shape."
|
||||
- "A regression fixture and decoder test are added."
|
||||
- "Rollback through 1C restores the SQL evidence or the experiment documents an irreversible schema migration."
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -10,6 +12,7 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -43,6 +46,7 @@ SUPPORTED_SUPPORT_MODES = {"none", "editable", "locked", "rules", "unknown"}
|
||||
_BASE_LOCKS: dict[str, threading.Lock] = {}
|
||||
_BASE_LOCKS_GUARD = threading.Lock()
|
||||
_STATE_LOCK = threading.RLock()
|
||||
REPOSITORY_STATE_SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
def external_1c_enabled() -> bool:
|
||||
@@ -124,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]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Typed write-dispatch contracts for the SQL-only 1C adapter."""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Explicit adapter services available to typed write handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterWriteContext:
|
||||
"""Migration boundary: handlers receive services, never server globals.
|
||||
|
||||
`legacy_scheduled_job_writer` is temporary while the existing proven
|
||||
implementation is characterized. It prevents the dispatcher from keeping
|
||||
a direct dependency on that writer and is replaced by granular services
|
||||
when the implementation body moves into the handler.
|
||||
"""
|
||||
|
||||
legacy_scheduled_job_writer: Callable[[dict[str, Any]], dict[str, Any]]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Stable contracts shared by the universal dispatcher and typed handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriteHandler:
|
||||
"""A supported public write surface, not an SQL implementation detail."""
|
||||
|
||||
key: str
|
||||
public_target_kind: str
|
||||
operation: str | None = None
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Typed write-handler declarations.
|
||||
|
||||
Implementations are migrated here one at a time after their existing adapter
|
||||
tests become handler-level characterization tests.
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
# Element, command/button, and embedded-module routing needs decoded form
|
||||
# evidence, so it remains a sub-dispatch inside this public form surface.
|
||||
HANDLER = WriteHandler("form", "form")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("module", "module")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("object_member", "object", "add_attribute")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("object_property", "object")
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from write.context import AdapterWriteContext
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("scheduled_job_schedule", "schedule")
|
||||
|
||||
|
||||
def execute(payload: dict[str, Any], context: AdapterWriteContext) -> dict[str, Any]:
|
||||
"""Run the current proven scheduled-job writer through the handler seam."""
|
||||
return context.legacy_scheduled_job_writer(payload)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Pure, storage-free selection of a typed configuration write handler.
|
||||
|
||||
The registry deliberately contains no SQL, payload, or 1C metadata decoding.
|
||||
It is the first migration seam out of the monolithic adapter server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from write.contracts import WriteHandler
|
||||
from write.handlers.form import HANDLER as FORM_HANDLER
|
||||
from write.handlers.module import HANDLER as MODULE_HANDLER
|
||||
from write.handlers.object_member import HANDLER as OBJECT_MEMBER_HANDLER
|
||||
from write.handlers.object_property import HANDLER as OBJECT_PROPERTY_HANDLER
|
||||
from write.handlers.scheduled_job import HANDLER as SCHEDULE_HANDLER
|
||||
|
||||
|
||||
def select_write_handler(*, target_kind: str, operation: str = "", is_schedule: bool = False) -> WriteHandler | None:
|
||||
"""Return one supported typed handler or ``None`` for a forbidden target.
|
||||
|
||||
Detailed form sub-routing (element, command, embedded module) remains in
|
||||
the form handler. It needs decoded target evidence that is unavailable at
|
||||
this pure public-intent stage.
|
||||
"""
|
||||
if is_schedule:
|
||||
return SCHEDULE_HANDLER
|
||||
normalized_kind = str(target_kind or "").strip().casefold()
|
||||
normalized_operation = str(operation or "").strip().casefold()
|
||||
if normalized_kind in {"object", "объект", "metadata", "метаданные"}:
|
||||
if normalized_operation in {"add_attribute", "attribute_add", "добавить_реквизит", "добавитьреквизит"}:
|
||||
return OBJECT_MEMBER_HANDLER
|
||||
return OBJECT_PROPERTY_HANDLER
|
||||
if normalized_kind in {"module", "модуль", "bsl"}:
|
||||
return MODULE_HANDLER
|
||||
if normalized_kind in {"form", "форма"}:
|
||||
return FORM_HANDLER
|
||||
return None
|
||||
|
||||
|
||||
def registered_handlers() -> list[dict[str, str | None]]:
|
||||
"""Public-safe registry summary; contains no SQL implementation details."""
|
||||
handlers = [MODULE_HANDLER, FORM_HANDLER, OBJECT_PROPERTY_HANDLER, OBJECT_MEMBER_HANDLER, SCHEDULE_HANDLER]
|
||||
return [
|
||||
{"key": handler.key, "target_kind": handler.public_target_kind, "operation": handler.operation}
|
||||
for handler in handlers
|
||||
]
|
||||
Reference in New Issue
Block a user