diff --git a/.gitignore b/.gitignore index 3f895b6..4e3328b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,5 @@ kotlin-example/ 1cv8/ ai-artefacts/ references/ -!SKILL/references/ -!SKILL/references/** +!v8-runner/references/ +!v8-runner/references/** diff --git a/AGENTS.md b/AGENTS.md index 99b2962..4424856 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,9 +24,9 @@ ## Repo-local SKILL -`SKILL/SKILL.md` — это скилл для использования `v8-runner` в других 1С-проектах. При реализации задач актуализировать его, если меняются команды, workflow, контракты конфигурации, ограничения или диагностические практики, важные для внешнего применения. +`v8-runner/SKILL.md` — это скилл для использования `v8-runner` в других 1С-проектах. При реализации задач актуализировать его, если меняются команды, workflow, контракты конфигурации, ограничения или диагностические практики, важные для внешнего применения. -Правки в `SKILL/SKILL.md` должны быть краткими, емкими и по делу: добавлять только применимые инструкции, без пересказа внутренних деталей реализации и без раздувания справки. +Правки в `v8-runner/SKILL.md` должны быть краткими, емкими и по делу: добавлять только применимые инструкции, без пересказа внутренних деталей реализации и без раздувания справки. ## Commit message format diff --git a/README.md b/README.md index e39aa89..6363112 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,15 @@ v8-runner test va v8-runner launch mcp va ``` +### Подключение к session-manager (WS-режим) + +`launch mcp`, `launch mcp va`, `test yaxunit ...` и `test va ...` поддерживают подключение +1С-клиента к [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager) вместо запуска +локального HTTP MCP. По умолчанию — `auto`: TCP-probe адреса менеджера, при успехе +собирается `/C"mcpMode=ws;manager_url=...;client_uid=...;kind=...;..."`, иначе используется +MCP `/C"runMcp;..."`. Полный список ключей `/C` и CLI-флагов см. в +[docs/CONFIGURATION.md](docs/CONFIGURATION.md#tools-client_mcp). + ### Поднимите MCP transport (MCP-транспорт) для AI-агентов: ```bash diff --git a/SKILL/references/project-workflows.md b/SKILL/references/project-workflows.md deleted file mode 100644 index 4f94c47..0000000 --- a/SKILL/references/project-workflows.md +++ /dev/null @@ -1,134 +0,0 @@ -# Project Workflows - -Use these flows by user intent. Do not split the workflow only because source files are Designer or EDT; many commands share the same lifecycle and differ only by `format`, `builder`, or tool availability. - -For exact support rules, read `config-and-backends.md` together with this file. - -## Bootstrap - -Create the default config when the project has no `v8project.yaml`: - -```bash -v8-runner config init -``` - -Choose a narrower init command only when the project shape is known: - -```bash -v8-runner config init --connection "File=build/ib" -v8-runner config init --format edt -v8-runner config init --builder IBCMD -``` - -Initialize generated runtime state only when the file infobase or EDT workspace needs to be created: - -```bash -v8-runner init -``` - -## Build - -Apply Git-visible source changes to the configured runtime state: - -```bash -v8-runner build -``` - -Use a full rebuild after branch switches, rebases, broad object moves, or suspicious incremental state: - -```bash -v8-runner build --full-rebuild -``` - -`build` is a common workflow. For EDT projects it may export EDT sources to Designer files before applying them through the configured backend. For Designer projects it applies Designer sources directly through the configured backend. - -If `tools.client_mcp.extension` is configured, `build` also prepares that tool extension after the project source-set stage, including scoped `--source-set` builds. Source-backed tool extensions use their own change-detection state and are skipped when unchanged; use `build --full-rebuild` to force refresh. Do not add a tool extension as a project `source-set` or select it with `--source-set`. - -## Syntax - -Choose syntax checks from config capabilities, not from assumptions about the repository name. - -Designer module checks: - -```bash -v8-runner build -v8-runner syntax designer-modules --server --thin-client -``` - -Designer configuration checks: - -```bash -v8-runner build -v8-runner syntax designer-config -``` - -EDT checks: - -```bash -v8-runner build -v8-runner syntax edt -``` - -If a syntax command is unavailable for the current `format` or `builder`, report the config limitation instead of inventing raw platform commands. - -## Dump - -Use dump when the desired source of truth is the current infobase state. - -Before dumping, inspect current Git changes: - -```bash -git status --short -``` - -Incremental dump: - -```bash -v8-runner dump --mode incremental -``` - -Partial object dump when the backend supports it: - -```bash -v8-runner dump --mode partial --object -``` - -Run `git diff` after dump and report the affected files. - -## Extensions - -Use `extensions` when extension properties need to be synchronized without a broader recovery step. - -Do not replace extension-specific synchronization with a full rebuild unless the user asks for recovery or the narrower command fails for a relevant reason. - -```bash -v8-runner extensions -v8-runner extensions --name -``` - -## Launch - -Prefer runner launch commands over raw `1cv8` command construction: - -```bash -v8-runner launch designer -v8-runner launch thin -v8-runner launch thick -v8-runner launch ordinary -``` - -Launch onec-client-mcp-devkit through the supported `launch mcp` surface instead of manually assembling `/C"runMcp..."`: - -```bash -v8-runner launch mcp -v8-runner launch mcp --mode thin --mcp-port -v8-runner launch mcp --mcp-config -``` - -For ordinary direct launches, typed launch flags include `--c`, `--execute`, `--use-privileged-mode`, `--output`, and repeatable `--raw-key`. - -For `launch mcp`, use `--mcp-config` and `--mcp-port`; do not pass `/C` through `--c`. - -`launch mcp` and `launch mcp va` do not install or update `tools.client_mcp.extension`; run `v8-runner build` first when that extension may be missing or stale. - -For `launch mcp va`, read `testing.md`; it is part of the Vanessa Automation debugging and scenario-authoring workflow. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 91dbae4..5018f9a 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -246,7 +246,10 @@ v8-runner launch mcp [va] [--mode ] [FLAGS] - `launch mcp --mode thick` использует `1cv8`; `launch mcp --mode ordinary` использует `1cv8` и добавляет `/RunModeOrdinaryApplication`. - `launch mcp va` дополнительно запускает Vanessa Automation из `tools.va` через `/Execute ` - и передаёт `VAParams=` без `StartFeaturePlayer`. + как тест-менеджер для MCP tools: добавляет `/TESTMANAGER` и + `/DisableUnsafeActionProtection`; WS-режим передаёт + `mcpMode=ws;...;VAParams=`, а local MCP fallback передаёт + `runMcp...;VAParams=` без `StartFeaturePlayer`. - Любой управляемый runner payload для ключа `/C` передаётся как один аргумент `/C""`: это касается `launch --c`, `launch mcp`, `test yaxunit` и `test va`. - Для `mcp` доступны typed flags `--mcp-config ` и `--mcp-port `; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f3adbfa..730ab91 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -141,6 +141,10 @@ tools: source: path: /path/to/onec-client-mcp/exts/client-mcp format: EDT + transport: auto # ws | mcp | auto (default) + manager_url: ws://127.0.0.1:4000/sessions + log_level: info # off|error|warn|info|debug|trace + ws_timeout_ms: 1000 va: epf_path: /path/to/vanessa.epf platform: @@ -442,11 +446,65 @@ runtime identity и не добавляет отдельное поле конф Поддержанные поля: -- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit. +- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit (MCP-режим). - `extension`, опциональное tool extension для клиентского MCP-сервера. +- `transport` (`ws`, `mcp`, `auto`; по умолчанию `auto`) — режим транспорта. См. раздел + «WS-режим к session-manager» ниже. +- `manager_url` — WS-эндпоинт `v8-client-session-manager` с IP-адресом и портом, + по умолчанию `ws://127.0.0.1:4000/sessions`. +- `log_level` (`off`/`error`/`warn`/`info`/`debug`/`trace`) — значение `mcp_log_level`, + передаваемое в `/C` BSL-расширению `client_mcp`. +- `ws_timeout_ms` — значение `mcp_ws_timeout_ms`, таймаут установки WS-сессии + в миллисекундах (> 0). -`launch mcp` передаёт это значение как `mcpPort` внутри `/C"runMcp..."` -если CLI не указал `--mcp-port`. +`launch mcp` передаёт `port` как `mcpPort` внутри `/C"runMcp..."` +если CLI не указал `--mcp-port` и выбран `mcp`/`auto`-fallback транспорт. + +#### WS-режим к session-manager + +Когда `transport=ws` или `transport=auto` и менеджер доступен по `manager_url`, +v8-runner собирает `/C` так: + +```text +/C"mcpMode=ws;manager_url=...;client_uid=;kind=;corr_id=...;mcp_log_level=...;mcp_ws_timeout_ms=..." +``` + +Значение `kind` вычисляется внутри по точке входа и не может быть переопределено +из CLI: + +| Команда v8-runner | `kind` | +|-------------------------|-----------------------| +| `launch mcp` | `v8_runner_client` | +| `launch mcp va` | `vanessa_test_client` | +| `test yaxunit ...` | `yaxunit_runner` | +| `test va ...` | `vanessa_test_client` | + +`launch mcp va` использует `kind=vanessa_test_client`: это режим менеджера Vanessa +для обследования/authoring. Runner добавляет `/TESTMANAGER`, +`/DisableUnsafeActionProtection` и `/Execute `. +В WS-режиме `/C` должен повторять проверенную строку +`mcpMode=ws;...;VAParams=`. +При `mcp`/fallback используется локальный HTTP MCP payload `runMcp...;VAParams=...`; +для него runner генерирует runtime `VAParams` и принудительно выставляет +`ВыполнитьСценарии=false`, `ЗавершитьРаботуСистемы=false`, +`ЗакрытьTestClientПослеЗапускаСценариев=false`, +`ЗакрыватьКлиентТестированияПринудительно=false`. + +Для тестовых запусков (`test yaxunit`, `test va`) WS-фрагмент **дописывается** к существующему +`/C` (`RunUnitTests=...` или Vanessa-плеер) через `;`. На стороне платформы все ключи +парсятся как `key=value` пары. + +CLI-флаги: `--mcp-transport`, `--manager-url`, `--client-uid`, `--corr-id`, +`--mcp-log-level`, `--mcp-ws-timeout-ms`. CLI имеет приоритет над конфигом, конфиг — над +встроенными дефолтами. `client_uid` по умолчанию рандомный UUIDv4 на каждый запуск, +`corr_id` по умолчанию `vr-<первые 8 символов uid>`. + +Для `transport=auto` v8-runner делает короткий TCP-probe (200 ms) на IP:порт из +`manager_url`. При успехе выбирается WS, иначе MCP. Для `transport=ws` без живого +менеджера запуск падает с ошибкой `session-manager unreachable at `. + +Сам менеджер v8-runner не запускает — его нужно поднять отдельно +(см. репозиторий [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager)). `extension` поддерживает: diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 457579a..b322bbd 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -204,6 +204,14 @@ ], "description": "Machine-local override or reset for the client MCP tool extension." }, + "log_level": { + "description": "Machine-local override of the default `mcp_log_level`.", + "type": "string" + }, + "manager_url": { + "description": "Machine-local override of the default session-manager WS endpoint.", + "type": "string" + }, "port": { "description": "Machine-local default port passed to onec-client-mcp-devkit.", "format": "uint16", @@ -213,6 +221,21 @@ "integer", "null" ] + }, + "transport": { + "description": "Machine-local override of the default MCP client transport.", + "enum": [ + "ws", + "mcp", + "auto" + ], + "type": "string" + }, + "ws_timeout_ms": { + "description": "Machine-local override of the default `mcp_ws_timeout_ms`.", + "format": "uint64", + "minimum": 1, + "type": "integer" } }, "type": "object" diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index d4cf607..dc0ba08 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -33,6 +33,20 @@ ], "description": "Optional tool extension prepared by `build` for client MCP launches." }, + "log_level": { + "description": "Default `mcp_log_level` value forwarded into the `/C` payload.", + "type": [ + "string", + "null" + ] + }, + "manager_url": { + "description": "Default WS endpoint with IP address and port for the session-manager.", + "type": [ + "string", + "null" + ] + }, "port": { "description": "Default port passed to onec-client-mcp-devkit.", "format": "uint16", @@ -42,6 +56,28 @@ "integer", "null" ] + }, + "transport": { + "description": "Default transport for the MCP client side: `ws`, `mcp` or `auto`.", + "enum": [ + "ws", + "mcp", + "auto", + null + ], + "type": [ + "string", + "null" + ] + }, + "ws_timeout_ms": { + "description": "Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload.", + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] } }, "type": "object" diff --git a/spec/decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md b/spec/decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md index 944bf4b..585aeb5 100644 --- a/spec/decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md +++ b/spec/decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md @@ -147,7 +147,7 @@ validation error, а не поздний platform failure. - добавить понятный hint на `v8-runner build`, если client MCP launch зависит от неподготовленной ИБ. 7. Docs/tests: - обновить `docs/CONFIGURATION.md`, `docs/CAPABILITIES.md` and examples; - - после реализации обновить `SKILL/SKILL.md`, чтобы repo-local skill описывал shipped behavior; + - после реализации обновить `v8-runner/SKILL.md`, чтобы repo-local skill описывал shipped behavior; - добавить targeted tests для source/artifact validation, EDT workspace init, build preparation, source-backed no-change skip, full-rebuild refresh and no-install launch behavior. diff --git a/src/cli/args.rs b/src/cli/args.rs index a4f427c..0b0e9d8 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -159,6 +159,9 @@ pub struct TestArgs { #[command(flatten)] pub launch: LaunchOptionsArgs, + #[command(flatten)] + pub mcp_ws: McpClientWsArgs, + #[command(subcommand)] pub runner: TestRunner, } @@ -311,6 +314,44 @@ pub struct LaunchArgs { /// Port override for onec-client-mcp-devkit `/C"...;mcpPort="` #[arg(long = "mcp-port")] pub mcp_port: Option, + + #[command(flatten)] + pub mcp_ws: McpClientWsArgs, +} + +/// Shared WS-mode (`mcpMode=ws`) options for `launch mcp`, `launch mcp va`, +/// `test yaxunit`, and `test va`. All flags are optional; values fall back to +/// the project config (`tools.client_mcp.*`) and finally to internal defaults. +#[derive(Args, Debug, Clone, Default, PartialEq, Eq)] +#[command(next_help_heading = "MCP client WS options")] +pub struct McpClientWsArgs { + /// Override the transport selection: `ws` forces WS, `mcp` forces local + /// HTTP MCP, `auto` probes the manager and falls back to MCP. + #[arg(long = "mcp-transport", value_parser = ["ws", "mcp", "auto"])] + pub mcp_transport: Option, + + /// Override the session-manager WS endpoint + /// (default `ws://127.0.0.1:4000/sessions`). + #[arg(long = "manager-url")] + pub manager_url: Option, + + /// Use a fixed `client_uid` instead of generating a fresh UUIDv4 + /// per launch. + #[arg(long = "client-uid")] + pub client_uid: Option, + + /// Override the `corr_id` used for trace correlation in manager logs. + #[arg(long = "corr-id")] + pub corr_id: Option, + + /// Override the `mcp_log_level` value passed to the BSL devkit. + #[arg(long = "mcp-log-level")] + pub mcp_log_level: Option, + + /// Override the `mcp_ws_timeout_ms` value passed to the BSL devkit + /// (WS-handshake timeout used by `auto` fallback). + #[arg(long = "mcp-ws-timeout-ms")] + pub mcp_ws_timeout_ms: Option, } #[derive(Args, Debug, Clone, Default, PartialEq, Eq)] @@ -680,6 +721,7 @@ mod tests { mcp_mode, mcp_config, mcp_port, + mcp_ws: _, }) => { assert_eq!(target, "ordinary"); assert_eq!(launch.c.as_deref(), Some("DoWork")); @@ -729,6 +771,7 @@ mod tests { mcp_mode, mcp_config, mcp_port, + mcp_ws: _, }) => { assert_eq!(target, "designer"); assert_eq!(launch, LaunchOptionsArgs::default()); @@ -765,6 +808,7 @@ mod tests { mcp_mode, mcp_config, mcp_port, + mcp_ws: _, }) => { assert_eq!(target, "mcp"); assert_eq!(launch, LaunchOptionsArgs::default()); diff --git a/src/cli/execute.rs b/src/cli/execute.rs index afde3a0..5310a91 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -713,6 +713,7 @@ fn map_extensions_request(args: &ExtensionsArgs) -> ConfigureExtensionsRequest { fn map_test_request(config: &AppConfig, args: &TestArgs) -> Result { let client_mode = map_test_client_mode(args.client_mode.as_deref())?; + let mcp_ws = map_mcp_ws_args(&args.mcp_ws)?; match &args.runner { TestRunner::Yaxunit(TestYaxunitArgs { scope }) => { let scope = map_yaxunit_scope(scope)?; @@ -720,12 +721,14 @@ fn map_test_request(config: &AppConfig, args: &TestArgs) -> Result Ok(TestRequest { execution: build_vanessa_execution(config, &args.launch, client_mode)?, full: args.full, scope: TestScopeRequest::All, + mcp_ws, }), } } @@ -1177,6 +1180,87 @@ fn map_launch_request(args: &LaunchArgs) -> Result target, launch: map_direct_launch_options(target, &args.launch, client_mcp.is_some())?, client_mcp, + mcp_ws: map_mcp_ws_args(&args.mcp_ws)?, + }) +} + +fn map_mcp_ws_args( + args: &crate::cli::args::McpClientWsArgs, +) -> Result { + use crate::use_cases::request::{McpClientTransportRequest, McpClientWsRequest}; + let transport = match args.mcp_transport.as_deref() { + None => None, + Some(value) => match crate::use_cases::mcp_ws::McpClientTransport::from_str_value(value) { + Some(crate::use_cases::mcp_ws::McpClientTransport::Ws) => { + Some(McpClientTransportRequest::Ws) + } + Some(crate::use_cases::mcp_ws::McpClientTransport::Mcp) => { + Some(McpClientTransportRequest::Mcp) + } + Some(crate::use_cases::mcp_ws::McpClientTransport::Auto) => { + Some(McpClientTransportRequest::Auto) + } + None => { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + format!("--mcp-transport must be one of: ws, mcp, auto (got: {value})"), + )); + } + }, + }; + if let Some(level) = args.mcp_log_level.as_deref() { + if !crate::use_cases::mcp_ws::is_supported_log_level(level) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + format!( + "--mcp-log-level must be one of: off, error, warn, info, debug, trace (got: {level})" + ), + )); + } + } + if args.mcp_ws_timeout_ms == Some(0) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--mcp-ws-timeout-ms must be greater than or equal to 1", + )); + } + if let Some(url) = args.manager_url.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(url) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--manager-url must not contain ';' or '=' because the /C payload is semicolon-delimited", + )); + } + if let Err(error) = crate::use_cases::mcp_ws::parse_manager_addr(url) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + format!("--manager-url parse error: {error}"), + )); + } + } + if let Some(uid) = args.client_uid.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(uid) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--client-uid must not contain ';' or '=' because the /C payload is semicolon-delimited", + )); + } + } + if let Some(corr) = args.corr_id.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(corr) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--corr-id must not contain ';' or '=' because the /C payload is semicolon-delimited", + )); + } + } + Ok(McpClientWsRequest { + transport, + manager_url: args.manager_url.clone(), + client_uid: args.client_uid.clone(), + corr_id: args.corr_id.clone(), + log_level: args.mcp_log_level.clone(), + ws_timeout_ms: args.mcp_ws_timeout_ms, }) } @@ -2275,6 +2359,7 @@ mod tests { name: "ModuleA".to_owned(), }, }), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }, ) .expect("request"); @@ -2303,6 +2388,7 @@ mod tests { name: " ".to_owned(), }, }), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }, ) .expect_err("blank module should be rejected"); @@ -2346,6 +2432,7 @@ mod tests { client_mode: None, launch: LaunchOptionsArgs::default(), runner: TestRunner::Va(TestVaArgs::default()), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }, ) .expect("request"); @@ -2437,6 +2524,7 @@ mod tests { }, mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect("request"), LaunchRequest { @@ -2450,6 +2538,7 @@ mod tests { raw_args: vec!["/WA-".to_owned(), "/DisplayAllFunctions".to_owned()], }, client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), } ); assert_eq!( @@ -2460,6 +2549,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect("request") .target, @@ -2473,6 +2563,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect("request") .target, @@ -2486,6 +2577,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: Some("C:\\tmp\\mcp-conf.json".to_owned()), mcp_port: Some(123), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect("request"), LaunchRequest { @@ -2503,6 +2595,7 @@ mod tests { port: Some(123), addon: Some(ClientMcpAddonRequest::VanessaAutomation), }), + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), } ); let load = map_load_request(&LoadArgs { @@ -2563,6 +2656,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect_err("launch mode should be rejected"); @@ -2772,6 +2866,7 @@ mod tests { runner: TestRunner::Yaxunit(TestYaxunitArgs { scope: TestScope::All, }), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), &presenter, false, @@ -2803,6 +2898,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), &presenter, false, @@ -2835,6 +2931,7 @@ mod tests { name: " ".to_owned(), }, }), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), &presenter, false, diff --git a/src/config/model.rs b/src/config/model.rs index f63b811..901b405 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -286,6 +286,21 @@ pub struct ClientMcpToolConfig { /// Optional tool extension prepared by `build` for client MCP launches. pub extension: Option, + + /// Default transport for the MCP client side: `ws`, `mcp` or `auto`. + /// When omitted, runtime treats it as `auto` (probe manager, fall back + /// to local HTTP MCP). + pub transport: Option, + + /// Default WS endpoint with IP address and port for the session-manager + /// (e.g. `ws://127.0.0.1:4000/sessions`). + pub manager_url: Option, + + /// Default `mcp_log_level` value forwarded into the `/C` payload. + pub log_level: Option, + + /// Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload. + pub ws_timeout_ms: Option, } #[derive(Debug, Clone)] diff --git a/src/config/schema.rs b/src/config/schema.rs index f974cc6..3eda552 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -27,6 +27,7 @@ pub fn main_config_schema_json() -> Value { set_schema_id(&mut schema, &main_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); + add_mcp_transport_schema_constraints(&mut schema); schema } @@ -36,6 +37,7 @@ pub fn local_config_schema_json() -> Value { set_schema_id(&mut schema, &local_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); + add_mcp_transport_schema_constraints(&mut schema); schema } @@ -233,6 +235,7 @@ fn add_numeric_runtime_bounds(schema: &mut Value) { ); for def in ["ClientMcpToolSchema", "PartialClientMcpToolSchema"] { set_numeric_bounds(schema, &[def], "port", Some(1), None); + set_numeric_bounds(schema, &[def], "ws_timeout_ms", Some(1), None); } for name in ["max_sessions", "idle_ttl_secs"] { set_numeric_bounds(schema, &["McpHttpSchema"], name, Some(1), None); @@ -279,6 +282,35 @@ fn set_numeric_bounds( } } +fn add_mcp_transport_schema_constraints(schema: &mut Value) { + for def in ["ClientMcpToolSchema", "PartialClientMcpToolSchema"] { + set_string_enum(schema, &[def], "transport", &["ws", "mcp", "auto"]); + } +} + +fn set_string_enum(schema: &mut Value, def_path: &[&str], property: &str, variants: &[&str]) { + let Some(object) = schema_object_mut(schema, def_path) else { + return; + }; + let Some(property) = object + .get_mut("properties") + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut(property)) + .and_then(Value::as_object_mut) + else { + return; + }; + + let mut variants = variants + .iter() + .map(|value| json!(value)) + .collect::>(); + if matches!(property.get("type"), Some(Value::Array(_))) { + variants.push(Value::Null); + } + property.insert("enum".to_owned(), Value::Array(variants)); +} + fn schema_object_mut<'a>( schema: &'a mut Value, def_path: &[&str], @@ -705,6 +737,18 @@ struct ClientMcpToolSchema { /// Optional tool extension prepared by `build` for client MCP launches. #[serde(default, skip_serializing_if = "Option::is_none")] extension: Option, + /// Default transport for the MCP client side: `ws`, `mcp` or `auto`. + #[serde(default, skip_serializing_if = "Option::is_none")] + transport: Option, + /// Default WS endpoint with IP address and port for the session-manager. + #[serde(default, skip_serializing_if = "Option::is_none")] + manager_url: Option, + /// Default `mcp_log_level` value forwarded into the `/C` payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + log_level: Option, + /// Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + ws_timeout_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -717,6 +761,38 @@ struct PartialClientMcpToolSchema { #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(with = "PartialToolExtensionSchema")] extension: Option, + /// Machine-local override of the default MCP client transport. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + transport: Option, + /// Machine-local override of the default session-manager WS endpoint. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + manager_url: Option, + /// Machine-local override of the default `mcp_log_level`. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + log_level: Option, + /// Machine-local override of the default `mcp_ws_timeout_ms`. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "u64")] + ws_timeout_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -1416,6 +1492,20 @@ mod tests { } } + #[test] + fn schemas_and_loader_reject_invalid_client_mcp_transport() { + let config = format!( + "{}tools:\n client_mcp:\n transport: legacy\n", + minimal_project_config_without_base_path() + ); + assert_schema_invalid(&main_config_schema_json(), &config); + assert_config_loader_error_any(&config); + + let overlay = "tools:\n client_mcp:\n transport: legacy\n"; + assert_schema_invalid(&local_config_schema_json(), overlay); + assert_overlay_loader_error(overlay); + } + #[test] fn schemas_and_loader_accept_supported_runtime_sections() { let config = format!( diff --git a/src/config/validate.rs b/src/config/validate.rs index b03dafa..5a2dc67 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -163,6 +163,20 @@ pub enum ConfigValidationError { #[error("tools.client_mcp.port must be greater than or equal to 1")] InvalidMcpClientPort, + #[error("tools.client_mcp.transport must be one of: ws, mcp, auto (got: {0})")] + InvalidMcpClientTransport(String), + + #[error( + "tools.client_mcp.log_level must be one of: off, error, warn, info, debug, trace (got: {0})" + )] + InvalidMcpClientLogLevel(String), + + #[error("tools.client_mcp.ws_timeout_ms must be greater than or equal to 1")] + InvalidMcpClientWsTimeoutMs, + + #[error("tools.client_mcp.manager_url must include host:port (got: {0})")] + InvalidMcpClientManagerUrl(String), + #[error("tools.client_mcp.extension.name must be a safe non-empty extension name: {0}")] InvalidToolExtensionName(String), @@ -815,6 +829,39 @@ fn validate_mcp_config(config: &AppConfig) -> Result<(), ConfigValidationError> return Err(ConfigValidationError::InvalidMcpClientPort); } + if let Some(transport) = config.tools.client_mcp.transport.as_deref() { + if crate::use_cases::mcp_ws::McpClientTransport::from_str_value(transport).is_none() { + return Err(ConfigValidationError::InvalidMcpClientTransport( + transport.to_owned(), + )); + } + } + + if let Some(level) = config.tools.client_mcp.log_level.as_deref() { + if !crate::use_cases::mcp_ws::is_supported_log_level(level) { + return Err(ConfigValidationError::InvalidMcpClientLogLevel( + level.to_owned(), + )); + } + } + + if config.tools.client_mcp.ws_timeout_ms == Some(0) { + return Err(ConfigValidationError::InvalidMcpClientWsTimeoutMs); + } + + if let Some(url) = config.tools.client_mcp.manager_url.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(url) { + return Err(ConfigValidationError::InvalidMcpClientManagerUrl( + url.to_owned(), + )); + } + if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { + return Err(ConfigValidationError::InvalidMcpClientManagerUrl( + url.to_owned(), + )); + } + } + Ok(()) } diff --git a/src/domain/launch.rs b/src/domain/launch.rs index 8219854..a0ba964 100644 --- a/src/domain/launch.rs +++ b/src/domain/launch.rs @@ -15,6 +15,36 @@ pub struct LaunchResult { pub binary: PathBuf, /// Human-readable launch summary. pub message: Option, + /// MCP transport selected for this launch. + /// Present only for `launch mcp` and `test` flows. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub transport: Option, + /// Per-launch UUID announced to the session-manager + /// (`mcpMode=ws` only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub client_uid: Option, + /// Manager-side client kind announced to the session-manager + /// (`mcpMode=ws` only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub kind: Option, + /// Session-manager WS endpoint used for this launch (`mcpMode=ws` only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub manager_url: Option, + /// Correlation id for trace correlation in manager logs + /// (`mcpMode=ws` only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub corr_id: Option, + /// Local HTTP MCP port (`mcp` transport only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub mcp_port: Option, +} + +/// MCP client transport selected for a launch result. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LaunchMcpTransport { + Ws, + Mcp, } /// Supported application launch modes. diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 4a7d003..465fa62 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -87,6 +87,7 @@ where execution: TestRequest::default_execution(), full: request.full.unwrap_or(false), scope: TestScopeRequest::All, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; match self @@ -131,6 +132,7 @@ where execution: TestRequest::default_execution(), full: request.full.unwrap_or(false), scope: TestScopeRequest::Module { name: module_name }, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; match self @@ -237,6 +239,7 @@ where })?, launch: LaunchOptions::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; let started = Instant::now(); @@ -1358,6 +1361,12 @@ mod tests { pid: Some(42), binary: PathBuf::from("/opt/1cv8"), message: None, + transport: None, + client_uid: None, + kind: None, + manager_url: None, + corr_id: None, + mcp_port: None, })); let config = sample_config(); let service = McpService::with_port(&config, port); @@ -1389,6 +1398,12 @@ mod tests { pid: None, binary: PathBuf::from("/opt/1cv8"), message: None, + transport: None, + client_uid: None, + kind: None, + manager_url: None, + corr_id: None, + mcp_port: None, })), ); @@ -1426,6 +1441,12 @@ mod tests { pid: None, binary: PathBuf::from("/opt/1cv8"), message: None, + transport: None, + client_uid: None, + kind: None, + manager_url: None, + corr_id: None, + mcp_port: None, })), ); diff --git a/src/platform/enterprise.rs b/src/platform/enterprise.rs index 08d8b7f..f187b89 100644 --- a/src/platform/enterprise.rs +++ b/src/platform/enterprise.rs @@ -144,6 +144,9 @@ pub fn build_launch_args( } } .to_owned()]; + if !matches!(mode, LaunchClientMode::Designer) { + args.extend(filtered_raw_launch_args(additional_launch_keys)); + } args.push("/DisableStartupDialogs".to_owned()); args.extend(connection.args()); if matches!(mode, LaunchClientMode::Ordinary) { @@ -157,13 +160,17 @@ pub fn build_launch_args( args.push(execute.clone()); } if let Some(c) = &launch.c { - args.push(quoted_c_arg(c)); + // `/C` and its payload must go as two separate argv tokens. + // std::process::Command bypasses the shell on both Linux (execve) and + // Windows (CreateProcess), so any surrounding `"..."` quoting would end + // up inside the value itself — the platform then sees `/C"…"` as a + // single unrecognized key and fails with «Неверные или отсутствующие + // параметры соединения с информационной базой». + args.push("/C".to_owned()); + args.push(c.clone()); } let mut extra_args = Vec::new(); - if !matches!(mode, LaunchClientMode::Designer) { - extra_args.extend(filtered_raw_launch_args(additional_launch_keys)); - } extra_args.extend(filtered_raw_launch_args(&launch.raw_args)); args.extend(extra_args); @@ -182,10 +189,6 @@ fn effective_out_path(launch: &LaunchOptions) -> Option<&str> { launch.internal_out.as_deref().or(launch.out.as_deref()) } -fn quoted_c_arg(payload: &str) -> String { - format!("/C\"{payload}\"") -} - fn filtered_raw_launch_args(args: &[String]) -> Vec { let mut filtered = Vec::new(); let mut skip_value = false; @@ -259,12 +262,16 @@ mod tests { ); assert_eq!(args[0], "ENTERPRISE"); - assert_eq!(args[1], "/DisableStartupDialogs"); assert!(args.iter().any(|arg| arg == "/TESTMANAGER")); + let position = |needle: &str| { + args.iter() + .position(|arg| arg == needle) + .unwrap_or_else(|| panic!("{needle} argument")) + }; + assert!(position("/TESTMANAGER") < position("/DisableStartupDialogs")); assert!(args - .iter() - .any(|arg| arg == "/C\"RunUnitTests=/tmp/path with space/тест config.json\"")); - assert!(!args.iter().any(|arg| arg == "/C")); + .windows(2) + .any(|pair| pair == ["/C", "RunUnitTests=/tmp/path with space/тест config.json"])); assert!(args.iter().any(|arg| arg == "/Out")); } @@ -289,9 +296,8 @@ mod tests { .any(|arg| arg == "/tmp/va/vanessa automation.epf")); assert!(args.iter().any(|arg| arg == "/TESTMANAGER")); assert!(args - .iter() - .any(|arg| arg == "/C\"StartFeaturePlayer;VAParams=/tmp/va/va-params.json\"")); - assert!(!args.iter().any(|arg| arg == "/C")); + .windows(2) + .any(|pair| pair == ["/C", "StartFeaturePlayer;VAParams=/tmp/va/va-params.json",])); } #[test] diff --git a/src/platform/process.rs b/src/platform/process.rs index f86f9f3..ee7992f 100644 --- a/src/platform/process.rs +++ b/src/platform/process.rs @@ -344,6 +344,18 @@ fn build_command(request: &ProcessRequest, io_mode: ProcessIoMode) -> Command { ProcessIoMode::Detached => { cmd.stdout(Stdio::null()); cmd.stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } } ProcessIoMode::Captured => { cmd.stdout(Stdio::piped()); @@ -760,6 +772,48 @@ mod tests { assert_eq!(result.binary, script); } + #[cfg(unix)] + #[test] + fn spawn_detached_starts_new_session_and_process_group() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("identity.sh"); + let identity_log = dir.path().join("identity.log"); + write_script( + &script, + "pid=$$\nsid=$(ps -o sid= -p \"$pid\" | tr -d ' ')\npgid=$(ps -o pgid= -p \"$pid\" | tr -d ' ')\nprintf '%s %s %s\\n' \"$pid\" \"$sid\" \"$pgid\" > \"$1\"\nsleep 0.2", + ); + + let runner = ProcessExecutor; + let result = runner + .spawn(&ProcessRequest { + program: script, + args: vec![identity_log.display().to_string()], + workdir: None, + stdout_log_path: None, + stderr_log_path: None, + startup_probe: Some(Duration::from_millis(50)), + }) + .expect("spawn"); + + let mut identity = String::new(); + for _ in 0..40 { + match fs::read_to_string(&identity_log) { + Ok(value) => { + identity = value; + break; + } + Err(_) => thread::sleep(Duration::from_millis(25)), + } + } + assert!(!identity.is_empty(), "identity log was not written"); + let parts: Vec = identity + .split_whitespace() + .map(|part| part.parse::().expect("numeric identity")) + .collect(); + + assert_eq!(parts, vec![result.pid, result.pid, result.pid]); + } + #[cfg(unix)] #[test] fn spawn_detects_immediate_exit_when_probe_is_requested() { diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index 5f0e0c3..09258be 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::time::Duration; use crate::config::model::AppConfig; -use crate::domain::launch::{LaunchMode, LaunchResult}; +use crate::domain::launch::{LaunchMcpTransport, LaunchMode, LaunchResult}; use crate::domain::runner::LaunchOptions; use crate::platform::enterprise::{ build_launch_args, normalize_launch_payload_path, LaunchClientMode, @@ -13,14 +13,18 @@ use crate::platform::utilities::PlatformUtilities; use crate::support::error::AppError; use crate::use_cases::context::{ExecutionContext, ExecutionInterruption}; use crate::use_cases::launch_keys::vanessa_enterprise_launch_keys; +use crate::use_cases::mcp_ws::{ + self, ClientKind, McpClientTransport, TransportDecision, WsLaunchParams, WsResolveInputs, +}; use crate::use_cases::progress::log_live_stage; use crate::use_cases::request::{ ClientMcpAddonRequest, ClientMcpMode, ClientMcpOptionsRequest, EnterpriseLaunchTarget, - LaunchRequest as LaunchArgs, LaunchTargetRequest, + LaunchRequest as LaunchArgs, LaunchTargetRequest, McpClientTransportRequest, + McpClientWsRequest, }; use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; use crate::use_cases::tool_extension; -use tracing::debug; +use tracing::{debug, warn}; const LAUNCH_STARTUP_PROBE: Duration = Duration::from_millis(250); @@ -65,7 +69,7 @@ pub fn execute( )))); } - let launch = effective_launch_options(config, args) + let (launch, mcp_resolution) = effective_launch_options(config, args) .map_err(|error| UseCaseFailure::without_payload(error))?; let additional_launch_keys = effective_enterprise_launch_keys(config, args, &launch); let mut utilities = PlatformUtilities::from_config(config); @@ -93,16 +97,54 @@ pub fn execute( }) .map_err(|error| UseCaseFailure::without_payload(AppError::from(error)))?; - let result = LaunchResult { + let mut result = LaunchResult { ok: true, mode, pid: Some(spawned.pid), binary: spawned.binary.clone(), message: Some(launch_message(config, args, &spawned.binary, spawned.pid)), + transport: None, + client_uid: None, + kind: None, + manager_url: None, + corr_id: None, + mcp_port: None, }; + if let Some(meta) = mcp_resolution { + apply_mcp_resolution_to_result(&mut result, meta); + } Ok(result) } +fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolutionMeta) { + match meta { + McpResolutionMeta::Ws(params) => { + result.transport = Some(LaunchMcpTransport::Ws); + result.client_uid = Some(params.client_uid); + result.kind = Some(params.kind.as_str().to_owned()); + result.manager_url = Some(params.manager_url); + result.corr_id = Some(params.corr_id); + } + McpResolutionMeta::Mcp { port } => { + result.transport = Some(LaunchMcpTransport::Mcp); + result.mcp_port = port; + } + McpResolutionMeta::DirectWs(params) => { + result.transport = Some(LaunchMcpTransport::Ws); + result.client_uid = Some(params.client_uid); + result.manager_url = Some(params.manager_url); + result.corr_id = Some(params.corr_id); + } + } +} + +#[derive(Debug, Clone)] +enum McpResolutionMeta { + Ws(WsLaunchParams), + DirectWs(WsLaunchParams), + Mcp { port: Option }, +} + fn launch_message(config: &AppConfig, args: &LaunchArgs, binary: &Path, pid: u32) -> String { let mut message = format!( "Launched {} via {} (pid {})", @@ -185,7 +227,7 @@ fn client_mcp_launch_shape(mode: ClientMcpMode) -> (LaunchMode, UtilityType, Lau fn effective_launch_options( config: &AppConfig, args: &LaunchArgs, -) -> Result { +) -> Result<(LaunchOptions, Option), AppError> { let is_client_mcp = matches!( args.target, LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::ClientMcp { .. }) @@ -195,8 +237,10 @@ fn effective_launch_options( Err(AppError::Validation( "launch mcp requires client_mcp options".to_owned(), )) + } else if direct_mcp_ws_requested(&args.mcp_ws) { + apply_direct_mcp_ws_launch(config, args) } else { - Ok(args.launch.clone()) + Ok((args.launch.clone(), None)) }; }; if !is_client_mcp { @@ -205,8 +249,29 @@ fn effective_launch_options( )); } + let kind = launch_mcp_client_kind(client_mcp); + let decision = decide_mcp_transport(config, &args.mcp_ws)?; let mut launch = args.launch.clone(); - let mut payload = build_client_mcp_payload(client_mcp, config.tools.client_mcp.port); + let (mut payload, meta) = match decision { + TransportDecision::Ws => { + if client_mcp.port.is_some() || config.tools.client_mcp.port.is_some() { + warn!( + "ws transport: client_mcp.port is ignored; session-manager controls the port" + ); + } + if client_mcp.config_path.is_some() { + warn!("ws transport: client_mcp.config_path is ignored"); + } + let params = resolve_ws_launch_params(config, &args.mcp_ws, kind); + let snippet = params.payload_snippet(); + (snippet, McpResolutionMeta::Ws(params)) + } + TransportDecision::Mcp => { + let payload = build_legacy_client_mcp_payload(client_mcp, config.tools.client_mcp.port); + let port = client_mcp.port.or(config.tools.client_mcp.port); + (payload, McpResolutionMeta::Mcp { port }) + } + }; if matches!( client_mcp.addon, Some(ClientMcpAddonRequest::VanessaAutomation) @@ -215,10 +280,69 @@ fn effective_launch_options( crate::use_cases::vanessa::apply_client_mcp_launch(&mut launch, &mut payload, &va_launch); } launch.c = Some(payload); - Ok(launch) + Ok((launch, Some(meta))) +} + +fn direct_mcp_ws_requested(cli: &McpClientWsRequest) -> bool { + cli.transport.is_some() + || cli.manager_url.is_some() + || cli.client_uid.is_some() + || cli.corr_id.is_some() + || cli.log_level.is_some() + || cli.ws_timeout_ms.is_some() +} + +fn apply_direct_mcp_ws_launch( + config: &AppConfig, + args: &LaunchArgs, +) -> Result<(LaunchOptions, Option), AppError> { + match args.target { + LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::ThinClient) + | LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::ThickClient) + | LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::OrdinaryApplication) => {} + LaunchTargetRequest::Designer + | LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::ClientMcp { .. }) => { + return Err(AppError::Validation( + "MCP WS launch options are supported only for enterprise client launches or launch mcp" + .to_owned(), + )); + } + } + + let decision = decide_mcp_transport(config, &args.mcp_ws)?; + match decision { + TransportDecision::Ws => { + let params = resolve_ws_launch_params(config, &args.mcp_ws, ClientKind::V8RunnerClient); + let mut launch = args.launch.clone(); + append_launch_payload(&mut launch, ¶ms.payload_snippet_without_kind()); + Ok((launch, Some(McpResolutionMeta::DirectWs(params)))) + } + TransportDecision::Mcp => Err(AppError::Validation( + "--mcp-transport=mcp is supported only for launch mcp".to_owned(), + )), + } } -fn build_client_mcp_payload( +fn append_launch_payload(launch: &mut LaunchOptions, snippet: &str) { + match &mut launch.c { + Some(payload) if !payload.is_empty() => { + payload.push(';'); + payload.push_str(snippet); + } + _ => { + launch.c = Some(snippet.to_owned()); + } + } +} + +fn launch_mcp_client_kind(client_mcp: &ClientMcpOptionsRequest) -> ClientKind { + match client_mcp.addon { + Some(ClientMcpAddonRequest::VanessaAutomation) => ClientKind::VanessaTestClient, + None => ClientKind::V8RunnerClient, + } +} + +fn build_legacy_client_mcp_payload( options: &ClientMcpOptionsRequest, configured_port: Option, ) -> String { @@ -232,6 +356,65 @@ fn build_client_mcp_payload( payload } +/// Resolves the effective transport for an MCP-bearing launch given CLI/config +/// inputs and a TCP probe of the manager. +pub(crate) fn decide_mcp_transport( + config: &AppConfig, + cli: &McpClientWsRequest, +) -> Result { + let requested = effective_transport(config, cli); + let manager_url = effective_manager_url(config, cli); + mcp_ws::select_transport(requested, &manager_url, |addr| { + mcp_ws::probe_tcp(addr, Duration::from_millis(mcp_ws::PROBE_TIMEOUT_MS)) + }) + .map_err(|err| AppError::Validation(err.to_string())) +} + +pub(crate) fn effective_transport( + config: &AppConfig, + cli: &McpClientWsRequest, +) -> McpClientTransport { + if let Some(t) = cli.transport { + return match t { + McpClientTransportRequest::Ws => McpClientTransport::Ws, + McpClientTransportRequest::Mcp => McpClientTransport::Mcp, + McpClientTransportRequest::Auto => McpClientTransport::Auto, + }; + } + config + .tools + .client_mcp + .transport + .as_deref() + .and_then(McpClientTransport::from_str_value) + .unwrap_or_default() +} + +pub(crate) fn effective_manager_url(config: &AppConfig, cli: &McpClientWsRequest) -> String { + cli.manager_url + .clone() + .or_else(|| config.tools.client_mcp.manager_url.clone()) + .unwrap_or_else(|| mcp_ws::DEFAULT_MANAGER_URL.to_owned()) +} + +pub(crate) fn resolve_ws_launch_params( + config: &AppConfig, + cli: &McpClientWsRequest, + kind: ClientKind, +) -> WsLaunchParams { + let inputs = WsResolveInputs { + manager_url: Some(effective_manager_url(config, cli)), + client_uid: cli.client_uid.clone(), + corr_id: cli.corr_id.clone(), + log_level: cli + .log_level + .clone() + .or_else(|| config.tools.client_mcp.log_level.clone()), + ws_timeout_ms: cli.ws_timeout_ms.or(config.tools.client_mcp.ws_timeout_ms), + }; + mcp_ws::resolve_ws_params(kind, inputs) +} + #[cfg(test)] mod tests { use super::execute; @@ -240,6 +423,7 @@ mod tests { SourceFormat, SourceSetConfig, SourceSetPurpose, TestsConfig, ToolExtensionArtifactConfig, ToolExtensionConfig, ToolExtensionInput, ToolsConfig, }; + use crate::domain::launch::LaunchMcpTransport; use crate::use_cases::context::{CommandName, ExecutionContext}; use crate::use_cases::request::{ ClientMcpMode, ClientMcpOptionsRequest, LaunchRequest, LaunchTargetRequest, @@ -315,6 +499,7 @@ mod tests { target: LaunchTargetRequest::thin_client(), launch: Default::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect("launch succeeds"); @@ -346,6 +531,7 @@ mod tests { target: LaunchTargetRequest::designer(), launch: Default::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect("launch succeeds"); @@ -377,6 +563,7 @@ mod tests { target: LaunchTargetRequest::ordinary_application(), launch: Default::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect("launch succeeds"); @@ -415,6 +602,10 @@ mod tests { target: LaunchTargetRequest::client_mcp_with_mode(ClientMcpMode::Thin), launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), + mcp_ws: crate::use_cases::request::McpClientWsRequest { + transport: Some(crate::use_cases::request::McpClientTransportRequest::Mcp), + ..Default::default() + }, }, ) .expect("launch succeeds"); @@ -425,9 +616,11 @@ mod tests { .as_deref() .expect("message") .contains("v8-runner build")); + assert_eq!(result.transport, Some(LaunchMcpTransport::Mcp)); + assert_eq!(result.mcp_port, Some(9874)); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("ENTERPRISE")); - assert!(args.contains("/C\"runMcp;mcpPort=9874\"")); + assert!(args.contains("/C\nrunMcp;mcpPort=9874\n")); assert!(!args.contains("/LoadCfg")); assert!(!args.contains("-Extension")); } @@ -446,6 +639,7 @@ mod tests { target: LaunchTargetRequest::client_mcp_with_mode(ClientMcpMode::Thin), launch: Default::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect_err("client_mcp options are required"); @@ -464,6 +658,7 @@ mod tests { target: LaunchTargetRequest::thin_client(), launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect_err("client_mcp options are rejected for non-mcp launch"); diff --git a/src/use_cases/launch_keys.rs b/src/use_cases/launch_keys.rs index dd573c7..6894d2d 100644 --- a/src/use_cases/launch_keys.rs +++ b/src/use_cases/launch_keys.rs @@ -1,18 +1,28 @@ use crate::domain::runner::LaunchOptions; pub(crate) const VANESSA_TEST_MANAGER_ARG: &str = "/TESTMANAGER"; +pub(crate) const DISABLE_UNSAFE_ACTION_PROTECTION_ARG: &str = "/DisableUnsafeActionProtection"; pub(crate) fn vanessa_enterprise_launch_keys( config_keys: &[String], launch: &LaunchOptions, ) -> Vec { let mut keys = config_keys.to_vec(); - if has_launch_key(&launch.raw_args, VANESSA_TEST_MANAGER_ARG) { - remove_launch_key(&mut keys, VANESSA_TEST_MANAGER_ARG); + ensure_vanessa_launch_key(&mut keys, &launch.raw_args, VANESSA_TEST_MANAGER_ARG); + ensure_vanessa_launch_key( + &mut keys, + &launch.raw_args, + DISABLE_UNSAFE_ACTION_PROTECTION_ARG, + ); + keys +} + +fn ensure_vanessa_launch_key(keys: &mut Vec, raw_args: &[String], key: &str) { + if has_launch_key(raw_args, key) { + remove_launch_key(keys, key); } else { - ensure_launch_key(&mut keys, VANESSA_TEST_MANAGER_ARG); + ensure_launch_key(keys, key); } - keys } fn ensure_launch_key(keys: &mut Vec, key: &str) { diff --git a/src/use_cases/mcp_ws.rs b/src/use_cases/mcp_ws.rs new file mode 100644 index 0000000..06f0586 --- /dev/null +++ b/src/use_cases/mcp_ws.rs @@ -0,0 +1,483 @@ +//! Shared helpers for assembling the WS-mode `/C` payload that connects +//! 1C-clients to `v8-client-session-manager` instead of the local +//! HTTP MCP server. +//! +//! The actual `/C` payload is parsed by the BSL extension `client_mcp` (see +//! `Мсп_ПараметрыЗапускаКлиент`). This module is responsible for: +//! +//! * choosing between the new WS transport and the local HTTP MCP transport, +//! * probing the manager's TCP socket when the transport is `auto`, +//! * generating per-launch `client_uid`/`corr_id` values, +//! * and serializing the final `key=value;...` snippet. +//! +//! Higher layers (`launch_app`, `run_tests`) decide where this snippet is +//! merged into the final `/C` value. + +use std::net::{IpAddr, SocketAddr, TcpStream}; +use std::time::Duration; + +use uuid::Uuid; + +/// Default WS endpoint when no override is supplied. +pub const DEFAULT_MANAGER_URL: &str = "ws://127.0.0.1:4000/sessions"; +/// Default log-level value when no override is supplied. +pub const DEFAULT_MCP_LOG_LEVEL: &str = "info"; +/// Default WS-handshake timeout when no override is supplied. +pub const DEFAULT_MCP_WS_TIMEOUT_MS: u64 = 1000; +/// Default TCP-probe timeout for `auto` transport detection. +pub const PROBE_TIMEOUT_MS: u64 = 200; + +/// Transport selector controlling how the MCP client connects to the +/// session-manager. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum McpClientTransport { + /// Force WS-only mode. Resolution fails if the manager is unreachable. + Ws, + /// Force the local HTTP MCP transport (`runMcp[=...][;mcpPort=...]`). + Mcp, + /// Probe the manager: WS when reachable, local HTTP MCP otherwise. + #[default] + Auto, +} + +impl McpClientTransport { + pub fn from_str_value(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "ws" => Some(Self::Ws), + "mcp" => Some(Self::Mcp), + "auto" => Some(Self::Auto), + _ => None, + } + } +} + +/// Internal client kind values selected by entry-point. Never exposed via CLI +/// flags; see the task brief for the reasoning. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientKind { + V8RunnerClient, + VanessaTestClient, + YaxunitRunner, +} + +impl ClientKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::V8RunnerClient => "v8_runner_client", + Self::VanessaTestClient => "vanessa_test_client", + Self::YaxunitRunner => "yaxunit_runner", + } + } +} + +/// Validated supported log-level values that the BSL devkit accepts. +const ALLOWED_LOG_LEVELS: &[&str] = &["off", "error", "warn", "info", "debug", "trace"]; + +/// Returns `true` when the value is one of the levels accepted by the devkit. +pub fn is_supported_log_level(level: &str) -> bool { + ALLOWED_LOG_LEVELS + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(level)) +} + +/// Returns `true` when a value can be embedded into the semicolon-delimited +/// `/C` payload without changing its key/value structure. +pub fn is_payload_token_safe(value: &str) -> bool { + !value.contains([';', '=']) +} + +/// Result of resolving the WS-mode connection parameters before launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WsLaunchParams { + pub manager_url: String, + pub client_uid: String, + pub kind: ClientKind, + pub corr_id: String, + pub log_level: String, + pub ws_timeout_ms: u64, +} + +impl WsLaunchParams { + /// Builds the `mcpMode=ws;...` snippet suitable for inclusion into the + /// platform's `/C` payload. + pub fn payload_snippet(&self) -> String { + format!( + "mcpMode=ws;manager_url={};client_uid={};kind={};corr_id={};mcp_log_level={};mcp_ws_timeout_ms={}", + encode_payload_token(&self.manager_url), + encode_payload_token(&self.client_uid), + self.kind.as_str(), + encode_payload_token(&self.corr_id), + encode_payload_token(&self.log_level), + self.ws_timeout_ms + ) + } + + /// Builds a WS snippet for a plain 1C client launch. In this mode the + /// client-side extension announces its own kind during `session.register`. + pub fn payload_snippet_without_kind(&self) -> String { + format!( + "mcpMode=ws;manager_url={};client_uid={};corr_id={};mcp_log_level={};mcp_ws_timeout_ms={}", + encode_payload_token(&self.manager_url), + encode_payload_token(&self.client_uid), + encode_payload_token(&self.corr_id), + encode_payload_token(&self.log_level), + self.ws_timeout_ms + ) + } +} + +fn encode_payload_token(value: &str) -> String { + value + .replace('%', "%25") + .replace(';', "%3B") + .replace('=', "%3D") +} + +/// Inputs to [`resolve_ws_params`]. Each field carries either an explicit +/// override (CLI takes precedence over config), or `None` to fall back to the +/// internal defaults. +#[derive(Debug, Clone, Default)] +pub struct WsResolveInputs { + pub manager_url: Option, + pub client_uid: Option, + pub corr_id: Option, + pub log_level: Option, + pub ws_timeout_ms: Option, +} + +impl WsResolveInputs { + pub fn manager_url_or_default(&self) -> String { + self.manager_url + .clone() + .unwrap_or_else(|| DEFAULT_MANAGER_URL.to_owned()) + } +} + +/// Resolves the final WS launch parameters for the given client kind. +pub fn resolve_ws_params(kind: ClientKind, inputs: WsResolveInputs) -> WsLaunchParams { + let manager_url = inputs.manager_url_or_default(); + let client_uid = inputs + .client_uid + .filter(|uid| !uid.trim().is_empty()) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let corr_id = inputs + .corr_id + .filter(|c| !c.trim().is_empty()) + .unwrap_or_else(|| default_corr_id(&client_uid)); + let log_level = inputs + .log_level + .filter(|l| !l.trim().is_empty()) + .map(|level| level.to_ascii_lowercase()) + .unwrap_or_else(|| DEFAULT_MCP_LOG_LEVEL.to_owned()); + let ws_timeout_ms = inputs.ws_timeout_ms.unwrap_or(DEFAULT_MCP_WS_TIMEOUT_MS); + WsLaunchParams { + manager_url, + client_uid, + kind, + corr_id, + log_level, + ws_timeout_ms, + } +} + +fn default_corr_id(client_uid: &str) -> String { + let mut short: String = client_uid.chars().filter(|c| *c != '-').take(8).collect(); + while short.len() < 8 { + short.push('0'); + } + format!("vr-{short}") +} + +/// Errors that can be produced while resolving the WS endpoint or probing it. +#[derive(Debug, thiserror::Error)] +pub enum WsResolveError { + #[error("invalid manager_url '{url}': {reason}")] + InvalidManagerUrl { url: String, reason: String }, + #[error("session-manager unreachable at {url}")] + Unreachable { url: String }, +} + +/// Decision returned by [`select_transport`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportDecision { + Ws, + Mcp, +} + +/// Selects the effective transport given the requested mode and a `probe` +/// callback that returns `true` when the manager TCP endpoint is reachable. +pub fn select_transport( + requested: McpClientTransport, + manager_url: &str, + probe: F, +) -> Result +where + F: FnOnce(SocketAddr) -> bool, +{ + match requested { + McpClientTransport::Mcp => Ok(TransportDecision::Mcp), + McpClientTransport::Ws => { + let addr = parse_manager_addr(manager_url)?; + if probe(addr) { + Ok(TransportDecision::Ws) + } else { + Err(WsResolveError::Unreachable { + url: manager_url.to_owned(), + }) + } + } + McpClientTransport::Auto => { + let addr = parse_manager_addr(manager_url)?; + if probe(addr) { + Ok(TransportDecision::Ws) + } else { + Ok(TransportDecision::Mcp) + } + } + } +} + +/// Default sync TCP probe used in production. Tries `connect_timeout` against +/// the resolved address. +pub fn probe_tcp(addr: SocketAddr, timeout: Duration) -> bool { + TcpStream::connect_timeout(&addr, timeout).is_ok() +} + +/// Parses the `host:port` portion of a `ws://host:port/path` URL. +/// +/// Hostnames are intentionally rejected: transport auto-detection must stay +/// bounded by the 200 ms TCP probe, and synchronous DNS lookup would not share +/// that timeout budget. +pub fn parse_manager_addr(url: &str) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err(WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "empty url".to_owned(), + }); + } + let after_scheme = match trimmed.find("://") { + Some(idx) => &trimmed[idx + 3..], + None => trimmed, + }; + let host_port = after_scheme.split('/').next().unwrap_or(after_scheme); + if host_port.is_empty() { + return Err(WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "missing host:port".to_owned(), + }); + } + let Some((host, port)) = host_port.rsplit_once(':') else { + return Err(WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "missing :port".to_owned(), + }); + }; + let host = host.trim_matches(['[', ']']); + let ip = host + .parse::() + .map_err(|_| WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "host must be an IP address".to_owned(), + })?; + let port = port + .parse::() + .map_err(|err| WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: err.to_string(), + })?; + Ok(SocketAddr::new(ip, port)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn transport_from_str_accepts_known_values() { + assert_eq!( + McpClientTransport::from_str_value("ws"), + Some(McpClientTransport::Ws) + ); + assert_eq!( + McpClientTransport::from_str_value("MCP"), + Some(McpClientTransport::Mcp) + ); + assert_eq!(McpClientTransport::from_str_value("legacy"), None); + assert_eq!( + McpClientTransport::from_str_value("auto"), + Some(McpClientTransport::Auto) + ); + assert_eq!(McpClientTransport::from_str_value("nope"), None); + } + + #[test] + fn parse_manager_addr_extracts_host_port() { + let addr = parse_manager_addr("ws://127.0.0.1:4000/sessions").expect("parse"); + assert_eq!(addr.port(), 4000); + } + + #[test] + fn parse_manager_addr_requires_port() { + let err = parse_manager_addr("ws://127.0.0.1/sessions").expect_err("rejected"); + assert!(matches!(err, WsResolveError::InvalidManagerUrl { .. })); + } + + #[test] + fn parse_manager_addr_rejects_empty() { + let err = parse_manager_addr("").expect_err("rejected"); + assert!(matches!(err, WsResolveError::InvalidManagerUrl { .. })); + } + + #[test] + fn select_transport_mcp_short_circuits() { + let decision = select_transport( + McpClientTransport::Mcp, + "ws://127.0.0.1:4000/sessions", + |_| panic!("probe must not be called"), + ) + .expect("mcp"); + assert_eq!(decision, TransportDecision::Mcp); + } + + #[test] + fn select_transport_auto_falls_back_to_mcp_when_unreachable() { + let decision = select_transport( + McpClientTransport::Auto, + "ws://127.0.0.1:4000/sessions", + |_| false, + ) + .expect("auto-fallback"); + assert_eq!(decision, TransportDecision::Mcp); + } + + #[test] + fn select_transport_ws_errors_when_unreachable() { + let err = select_transport( + McpClientTransport::Ws, + "ws://127.0.0.1:4000/sessions", + |_| false, + ) + .expect_err("ws-required"); + assert!(matches!(err, WsResolveError::Unreachable { .. })); + } + + #[test] + fn select_transport_ws_uses_probe_result() { + let decision = select_transport( + McpClientTransport::Ws, + "ws://127.0.0.1:4000/sessions", + |_| true, + ) + .expect("ws-up"); + assert_eq!(decision, TransportDecision::Ws); + } + + #[test] + fn probe_tcp_succeeds_against_listener() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); + let addr = listener.local_addr().expect("local addr"); + assert!(probe_tcp(addr, Duration::from_millis(500))); + } + + #[test] + fn probe_tcp_fails_when_no_listener() { + let addr = SocketAddr::from(([127, 0, 0, 1], 1)); + let connected = probe_tcp(addr, Duration::from_millis(50)); + assert!(!connected); + } + + #[test] + fn resolve_ws_params_uses_defaults_when_inputs_empty() { + let params = resolve_ws_params(ClientKind::V8RunnerClient, WsResolveInputs::default()); + assert_eq!(params.manager_url, DEFAULT_MANAGER_URL); + assert_eq!(params.kind, ClientKind::V8RunnerClient); + assert_eq!(params.log_level, DEFAULT_MCP_LOG_LEVEL); + assert_eq!(params.ws_timeout_ms, DEFAULT_MCP_WS_TIMEOUT_MS); + assert!(!params.client_uid.is_empty()); + assert!(params.corr_id.starts_with("vr-")); + assert_eq!(params.corr_id.len(), "vr-".len() + 8); + } + + #[test] + fn resolve_ws_params_honors_overrides() { + let inputs = WsResolveInputs { + manager_url: Some("ws://manager:5555/sessions".to_owned()), + client_uid: Some("00000000-0000-0000-0000-000000000001".to_owned()), + corr_id: Some("parent/vr-deadbeef".to_owned()), + log_level: Some("debug".to_owned()), + ws_timeout_ms: Some(2500), + }; + let params = resolve_ws_params(ClientKind::YaxunitRunner, inputs); + assert_eq!(params.manager_url, "ws://manager:5555/sessions"); + assert_eq!(params.client_uid, "00000000-0000-0000-0000-000000000001"); + assert_eq!(params.corr_id, "parent/vr-deadbeef"); + assert_eq!(params.log_level, "debug"); + assert_eq!(params.ws_timeout_ms, 2500); + assert_eq!(params.kind, ClientKind::YaxunitRunner); + } + + #[test] + fn resolve_ws_params_pads_default_corr_id_for_short_uid() { + let params = resolve_ws_params( + ClientKind::V8RunnerClient, + WsResolveInputs { + client_uid: Some("abc".to_owned()), + ..Default::default() + }, + ); + assert_eq!(params.corr_id, "vr-abc00000"); + } + + #[test] + fn payload_snippet_contains_all_keys_in_order() { + let params = WsLaunchParams { + manager_url: "ws://m:1/s".to_owned(), + client_uid: "uid".to_owned(), + kind: ClientKind::VanessaTestClient, + corr_id: "c".to_owned(), + log_level: "info".to_owned(), + ws_timeout_ms: 1000, + }; + assert_eq!( + params.payload_snippet(), + "mcpMode=ws;manager_url=ws://m:1/s;client_uid=uid;kind=vanessa_test_client;corr_id=c;mcp_log_level=info;mcp_ws_timeout_ms=1000" + ); + } + + #[test] + fn payload_snippet_encodes_delimiters() { + let params = WsLaunchParams { + manager_url: "ws://127.0.0.1:1/s".to_owned(), + client_uid: "uid=1".to_owned(), + kind: ClientKind::V8RunnerClient, + corr_id: "corr;1".to_owned(), + log_level: "info".to_owned(), + ws_timeout_ms: 1000, + }; + assert_eq!( + params.payload_snippet(), + "mcpMode=ws;manager_url=ws://127.0.0.1:1/s;client_uid=uid%3D1;kind=v8_runner_client;corr_id=corr%3B1;mcp_log_level=info;mcp_ws_timeout_ms=1000" + ); + } + + #[test] + fn is_supported_log_level_accepts_known_values() { + for level in ["off", "error", "warn", "info", "debug", "trace"] { + assert!(is_supported_log_level(level), "expected {level} supported"); + } + assert!(is_supported_log_level("INFO")); + assert!(!is_supported_log_level("verbose")); + } + + #[test] + fn client_kind_strings_match_session_manager_contract() { + assert_eq!(ClientKind::V8RunnerClient.as_str(), "v8_runner_client"); + assert_eq!( + ClientKind::VanessaTestClient.as_str(), + "vanessa_test_client" + ); + assert_eq!(ClientKind::YaxunitRunner.as_str(), "yaxunit_runner"); + } +} diff --git a/src/use_cases/mod.rs b/src/use_cases/mod.rs index d946807..fae65a6 100644 --- a/src/use_cases/mod.rs +++ b/src/use_cases/mod.rs @@ -32,6 +32,8 @@ pub mod launch_app; mod launch_keys; /// Load packaged artifacts into infobase. pub mod load_artifact; +/// Shared helpers for the WS-mode (`mcpMode=ws`) `/C` payload. +pub mod mcp_ws; /// Text-mode live progress events shared by CLI-facing use cases. mod progress; /// Transport-neutral request DTOs consumed by use cases. diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index 56bcb9e..6524a2c 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -39,6 +39,8 @@ pub struct TestRequest { pub full: bool, /// Selected test scope. Module targets require a non-empty module name. pub scope: TestScopeRequest, + /// Optional WS-mode (session-manager) overrides shared with `launch mcp`. + pub mcp_ws: McpClientWsRequest, } impl TestRequest { @@ -608,6 +610,30 @@ pub struct LaunchRequest { pub launch: LaunchOptions, /// Client-side MCP launch options. Present only for `LaunchTargetRequest::client_mcp*`. pub client_mcp: Option, + /// Optional WS-mode (session-manager) overrides shared between + /// `launch mcp` and `test` flows. + pub mcp_ws: McpClientWsRequest, +} + +/// Transport-neutral selector for MCP client transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpClientTransportRequest { + Ws, + Mcp, + Auto, +} + +/// Transport-neutral overrides for the WS-mode `/C` snippet. All fields are +/// optional; the use case fills missing values from project config and +/// internal defaults at execution time. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct McpClientWsRequest { + pub transport: Option, + pub manager_url: Option, + pub client_uid: Option, + pub corr_id: Option, + pub log_level: Option, + pub ws_timeout_ms: Option, } /// Transport-neutral request for the `init` use case. diff --git a/src/use_cases/run_tests.rs b/src/use_cases/run_tests.rs index 08e123f..d24e8dc 100644 --- a/src/use_cases/run_tests.rs +++ b/src/use_cases/run_tests.rs @@ -34,8 +34,8 @@ mod coordinator; mod helpers; use self::helpers::{ - build_enterprise_dsl, build_platform_launch, build_summary, capped_timeout_ms, - collect_diagnostics, degraded_step, enterprise_error_kind, failed_step, + apply_test_mcp_ws_payload, build_enterprise_dsl, build_platform_launch, build_summary, + capped_timeout_ms, collect_diagnostics, degraded_step, enterprise_error_kind, failed_step, interrupted_test_failure, make_test_result, prepare_runner_artifacts, prepared_run_summary, succeeded_step, validate_runner_profile_id, validate_target, with_retained_artifacts, }; @@ -730,6 +730,7 @@ mod tests { policy: ExecutionPolicy::default(), launch: LaunchOptions::default(), }, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; let context = ExecutionContext::cli(CommandName::Test); @@ -761,6 +762,7 @@ mod tests { policy: ExecutionPolicy::default(), launch: LaunchOptions::default(), }, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; let failure = run_tests(&context, &config, &args).expect_err("cancelled"); diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 2a7bf44..646e95c 100644 --- a/src/use_cases/run_tests/coordinator.rs +++ b/src/use_cases/run_tests/coordinator.rs @@ -226,7 +226,27 @@ pub(super) fn run_tests( log_live_stage("test: enterprise run", "[Enterprise] running test runner"); let run_started = Instant::now(); let enterprise_runner = crate::platform::process::ProcessExecutor; - let platform_launch = build_platform_launch(&args.execution.launch, &prepared_run, &artifacts); + let mut platform_launch = + build_platform_launch(&args.execution.launch, &prepared_run, &artifacts); + if let Err(error) = + apply_test_mcp_ws_payload(config, &args.mcp_ws, &prepared_run, &mut platform_launch) + { + let outcome = ExecutionOutcome::new(ExecutionStatus::Failed) + .with_diagnostics(vec![error.to_string()]) + .with_errors(vec![test_execution_error( + TestErrorKind::TestSetupFailed, + error.to_string(), + )]); + let result = make_test_result( + target, + mode, + outcome, + warnings, + steps, + started.elapsed().as_millis() as u64, + ); + return Err(TestExecutionFailure::with_payload(error, result)); + } let enterprise = match build_enterprise_dsl( context, config, diff --git a/src/use_cases/run_tests/helpers.rs b/src/use_cases/run_tests/helpers.rs index 951bc70..e0a0f38 100644 --- a/src/use_cases/run_tests/helpers.rs +++ b/src/use_cases/run_tests/helpers.rs @@ -415,6 +415,43 @@ pub(super) fn build_platform_launch( launch } +/// Appends the `mcpMode=ws;...` snippet to an existing `/C` payload, joining +/// with `;` and preserving the leading payload (e.g. `RunUnitTests=...` or +/// the Vanessa player payload). When the `/C` value is absent, the snippet +/// becomes the entire `/C`. +pub(super) fn append_mcp_ws_snippet(launch: &mut LaunchOptions, snippet: &str) { + let combined = match launch.c.take() { + Some(existing) if !existing.is_empty() => format!("{existing};{snippet}"), + _ => snippet.to_owned(), + }; + launch.c = Some(combined); +} + +/// If the WS-mode resolution succeeds for this test run, append the +/// `mcpMode=ws;...` snippet to the platform `/C` so the BSL devkit registers +/// with `v8-client-session-manager` instead of starting a local HTTP MCP. +/// +/// Errors from explicit WS resolution are returned to the caller. Auto mode +/// still falls back to the regular `/C` payload when the manager is down. +pub(super) fn apply_test_mcp_ws_payload( + config: &AppConfig, + mcp_ws: &crate::use_cases::request::McpClientWsRequest, + prepared_run: &PreparedRun, + launch: &mut LaunchOptions, +) -> Result<(), AppError> { + let kind = match prepared_run { + PreparedRun::YaXUnit => crate::use_cases::mcp_ws::ClientKind::YaxunitRunner, + PreparedRun::Vanessa { .. } => crate::use_cases::mcp_ws::ClientKind::VanessaTestClient, + }; + let decision = crate::use_cases::launch_app::decide_mcp_transport(config, mcp_ws)?; + if !matches!(decision, crate::use_cases::mcp_ws::TransportDecision::Ws) { + return Ok(()); + } + let params = crate::use_cases::launch_app::resolve_ws_launch_params(config, mcp_ws, kind); + append_mcp_ws_snippet(launch, ¶ms.payload_snippet()); + Ok(()) +} + pub(super) fn collect_diagnostics( platform_result: &crate::platform::result::PlatformCommandResult, mut diagnostics: Vec, @@ -498,3 +535,39 @@ pub(super) fn enterprise_error_kind( ), } } + +#[cfg(test)] +mod append_ws_tests { + use super::append_mcp_ws_snippet; + use crate::domain::runner::LaunchOptions; + + #[test] + fn append_appends_after_existing_payload() { + let mut launch = LaunchOptions { + c: Some("RunUnitTests=/tmp/cfg.json".to_owned()), + ..Default::default() + }; + append_mcp_ws_snippet(&mut launch, "mcpMode=ws;manager_url=ws://m:1/s"); + assert_eq!( + launch.c.as_deref(), + Some("RunUnitTests=/tmp/cfg.json;mcpMode=ws;manager_url=ws://m:1/s") + ); + } + + #[test] + fn append_uses_snippet_alone_when_c_missing() { + let mut launch = LaunchOptions::default(); + append_mcp_ws_snippet(&mut launch, "mcpMode=ws"); + assert_eq!(launch.c.as_deref(), Some("mcpMode=ws")); + } + + #[test] + fn append_replaces_empty_c() { + let mut launch = LaunchOptions { + c: Some(String::new()), + ..Default::default() + }; + append_mcp_ws_snippet(&mut launch, "mcpMode=ws"); + assert_eq!(launch.c.as_deref(), Some("mcpMode=ws")); + } +} diff --git a/src/use_cases/vanessa.rs b/src/use_cases/vanessa.rs index db6ce1e..873be3f 100644 --- a/src/use_cases/vanessa.rs +++ b/src/use_cases/vanessa.rs @@ -91,6 +91,7 @@ pub(crate) fn prepare_client_mcp_launch(config: &AppConfig) -> Result, artifacts: VanessaTestArt Value::Bool(true), ); object.insert("ДелатьОтчетВФорматеjUnit".to_owned(), Value::Bool(true)); - object.insert( - "КаталогВыгрузкиJUnit".to_owned(), - Value::String(artifacts.junit_dir.display().to_string()), - ); + let junit_dir = Value::String(artifacts.junit_dir.display().to_string()); + object.insert("КаталогВыгрузкиJUnit".to_owned(), junit_dir.clone()); + ensure_object(object, "ОтчетJUnit").insert("КаталогВыгрузкиJUnit".to_owned(), junit_dir); apply_logging_overlay( object, artifacts.runner_log, @@ -237,6 +237,29 @@ fn apply_test_overlay(object: &mut Map, artifacts: VanessaTestArt ); } +fn apply_manager_overlay(object: &mut Map) { + object.insert("ВыполнитьСценарии".to_owned(), Value::Bool(false)); + object.insert("ЗавершитьРаботуСистемы".to_owned(), Value::Bool(false)); + object.insert( + "ЗакрытьTestClientПослеЗапускаСценариев".to_owned(), + Value::Bool(false), + ); + object.insert( + "ЗакрыватьКлиентТестированияПринудительно".to_owned(), + Value::Bool(false), + ); +} + +fn ensure_object<'a>(object: &'a mut Map, key: &str) -> &'a mut Map { + if !object.get(key).is_some_and(Value::is_object) { + object.insert(key.to_owned(), Value::Object(Map::new())); + } + object + .get_mut(key) + .and_then(Value::as_object_mut) + .expect("object value was just inserted") +} + fn apply_logging_overlay( object: &mut Map, text_log_path: &Path, diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index d4d94c2..141136e 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -109,7 +109,11 @@ fn setup_mcp_va_project_with_options( fs::create_dir_all(va_params.parent().expect("cfg dir")).expect("cfg dir"); fs::create_dir_all(&features_dir).expect("features"); fs::write(&va_epf, "epf").expect("epf"); - fs::write(&va_params, "{\n \"existing\": true\n}\n").expect("params"); + fs::write( + &va_params, + "{\n \"existing\": true,\n \"ВыполнитьСценарии\": true,\n \"ЗавершитьРаботуСистемы\": true,\n \"ЗакрытьTestClientПослеЗапускаСценариев\": true,\n \"ЗакрыватьКлиентТестированияПринудительно\": true\n}\n", + ) + .expect("params"); fs::write(features_dir.join("login.feature"), "Feature: Login\n").expect("feature"); write_script(&install_dir.join("bin").join("1cv8c")); write_logging_script(&install_dir.join("bin").join("1cv8"), &args_log); @@ -361,7 +365,7 @@ fn launch_ordinary_supports_typed_keys_and_filters_reserved_raw_duplicates() { assert!(args.contains("/UsePrivilegedMode")); assert!(args.contains("/Execute")); assert!(args.contains("/tmp/tool.epf")); - assert!(args.contains("/C\"DoWork\"")); + assert!(args.contains("/C\nDoWork\n")); assert!(args.contains("DoWork")); assert!(args.contains("/WA-")); assert!(args.contains("/tmp/user.out.log")); @@ -383,6 +387,8 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { "ordinary", "--mcp-config", "/tmp/mcp conf.json", + "--mcp-transport", + "mcp", "--raw-key", "/WA-", ]) @@ -398,6 +404,8 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["data"]["mode"], "mcp"); + assert_eq!(payload["data"]["transport"], "mcp"); + assert_eq!(payload["data"]["mcp_port"], 9874); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), install_dir.join("bin").join("1cv8").to_string_lossy() @@ -409,9 +417,10 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert!(args.contains("/RunModeOrdinaryApplication")); assert!(args.contains("/Execute")); assert!(args.contains("vanessa-automation.epf")); - assert!(args.contains("/C\"runMcp=/tmp/mcp conf.json;mcpPort=9874;VAParams=")); + assert!(args.contains("/C\nrunMcp=/tmp/mcp conf.json;mcpPort=9874;VAParams=")); assert!(!args.contains("StartFeaturePlayer")); assert!(args.contains("/TESTMANAGER")); + assert!(args.contains("/DisableUnsafeActionProtection")); assert!(args.contains("/WA-")); let params_arg = args .lines() @@ -432,6 +441,13 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert_eq!(params_json["ОстановкаПриВозникновенииОшибки"], false); assert_eq!(params_json["СписокФичДляВыполнения"][0], "login"); assert_eq!(params_json["СписокТеговОтбор"][0], "smoke"); + assert_eq!(params_json["ВыполнитьСценарии"], false); + assert_eq!(params_json["ЗавершитьРаботуСистемы"], false); + assert_eq!(params_json["ЗакрытьTestClientПослеЗапускаСценариев"], false); + assert_eq!( + params_json["ЗакрыватьКлиентТестированияПринудительно"], + false + ); assert_eq!( params_json["ДелатьЛогВыполненияСценариевВТекстовыйФайл"], true @@ -469,9 +485,175 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { } #[test] -fn launch_mcp_va_does_not_duplicate_explicit_testmanager_raw_key() { - let (_dir, config_path, _install_dir, args_log) = - setup_mcp_va_project_with_options("work", &["/TESTMANAGER"]); +fn launch_mcp_va_ws_transport_emits_vanessa_test_client_payload() { + let (_dir, config_path, _install_dir, args_log) = setup_mcp_va_project(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let manager_url = format!( + "ws://127.0.0.1:{}/sessions", + listener.local_addr().expect("addr").port() + ); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "va", + "--mode", + "ordinary", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + "--mcp-log-level", + "debug", + "--mcp-ws-timeout-ms", + "2500", + "--client-uid", + "00000000-0000-0000-0000-000000000c0d", + "--corr-id", + "va-manager", + ]) + .output() + .expect("run command"); + drop(listener); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["mode"], "mcp"); + assert_eq!(payload["data"]["transport"], "ws"); + assert_eq!(payload["data"]["kind"], "vanessa_test_client"); + assert_eq!(payload["data"]["manager_url"], manager_url); + assert_eq!(payload["data"]["corr_id"], "va-manager"); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/Execute")); + assert!(args.contains("vanessa-automation.epf")); + assert!(args.contains("/TESTMANAGER")); + assert!(args.contains("/DisableUnsafeActionProtection")); + assert!(args.contains("/C\nmcpMode=ws;")); + assert!(args.contains("kind=vanessa_test_client")); + assert!(args.contains("client_uid=00000000-0000-0000-0000-000000000c0d")); + assert!(args.contains(&format!("manager_url={manager_url}"))); + assert!(args.contains("corr_id=va-manager")); + assert!(args.contains("mcp_log_level=debug")); + assert!(args.contains("mcp_ws_timeout_ms=2500")); + assert!(args.contains(";VAParams=")); + assert!(!args.contains("runMcp")); + assert!(!args.contains("StartFeaturePlayer")); + let lines: Vec<&str> = args.lines().collect(); + let pos = |needle: &str| { + lines + .iter() + .position(|line| *line == needle) + .unwrap_or_else(|| panic!("{needle} argument")) + }; + assert!(pos("/TESTMANAGER") < pos("/DisableStartupDialogs")); + assert!(pos("/DisableUnsafeActionProtection") < pos("/IBConnectionString")); + assert!(pos("/TESTMANAGER") < pos("/Execute")); + assert!(pos("/DisableUnsafeActionProtection") < pos("/C")); + + let params_arg = args + .lines() + .find(|line| line.contains("VAParams=")) + .expect("VAParams argument"); + let params_path = params_arg + .split("VAParams=") + .nth(1) + .expect("VAParams path") + .trim_end_matches('"'); + let params_json: Value = + serde_json::from_str(&fs::read_to_string(params_path).expect("runtime params")) + .expect("runtime params JSON"); + assert_eq!(params_json["ВыполнитьСценарии"], false); + assert_eq!(params_json["ЗавершитьРаботуСистемы"], false); + assert_eq!(params_json["ЗакрытьTestClientПослеЗапускаСценариев"], false); + assert_eq!( + params_json["ЗакрыватьКлиентТестированияПринудительно"], + false + ); +} + +#[test] +fn launch_mcp_va_auto_transport_falls_back_to_manager_mcp_payload() { + let (_dir, config_path, _install_dir, args_log) = setup_mcp_va_project(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + let manager_url = format!("ws://127.0.0.1:{port}/sessions"); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "va", + "--mode", + "ordinary", + "--mcp-transport", + "auto", + "--manager-url", + &manager_url, + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["mode"], "mcp"); + assert_eq!(payload["data"]["transport"], "mcp"); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/Execute")); + assert!(args.contains("vanessa-automation.epf")); + assert!(args.contains("/TESTMANAGER")); + assert!(args.contains("/DisableUnsafeActionProtection")); + assert!(args.contains("/C\nrunMcp")); + assert!(args.contains(";VAParams=")); + assert!(!args.contains("mcpMode=ws")); + assert!(!args.contains("StartFeaturePlayer")); + + let params_arg = args + .lines() + .find(|line| line.contains("VAParams=")) + .expect("VAParams argument"); + let params_path = params_arg + .split("VAParams=") + .nth(1) + .expect("VAParams path") + .trim_end_matches('"'); + let params_json: Value = + serde_json::from_str(&fs::read_to_string(params_path).expect("runtime params")) + .expect("runtime params JSON"); + assert_eq!(params_json["ВыполнитьСценарии"], false); + assert_eq!(params_json["ЗавершитьРаботуСистемы"], false); + assert_eq!(params_json["ЗакрытьTestClientПослеЗапускаСценариев"], false); + assert_eq!( + params_json["ЗакрыватьКлиентТестированияПринудительно"], + false + ); +} + +#[test] +fn launch_mcp_va_does_not_duplicate_explicit_vanessa_raw_keys() { + let (_dir, config_path, _install_dir, args_log) = setup_mcp_va_project_with_options( + "work", + &["/TESTMANAGER", "/DisableUnsafeActionProtection"], + ); let output = v8_runner_command() .args([ "--config", @@ -483,6 +665,8 @@ fn launch_mcp_va_does_not_duplicate_explicit_testmanager_raw_key() { "ordinary", "--raw-key", "/TESTMANAGER", + "--raw-key", + "/DisableUnsafeActionProtection", ]) .output() .expect("run command"); @@ -501,6 +685,11 @@ fn launch_mcp_va_does_not_duplicate_explicit_testmanager_raw_key() { .filter(|arg| arg.eq_ignore_ascii_case("/TESTMANAGER")) .count(); assert_eq!(test_manager_count, 1); + let unsafe_action_protection_count = args + .split_whitespace() + .filter(|arg| arg.eq_ignore_ascii_case("/DisableUnsafeActionProtection")) + .count(); + assert_eq!(unsafe_action_protection_count, 1); } #[test] @@ -682,3 +871,309 @@ fn launch_non_mcp_rejects_mcp_options() { "--mcp-config, --mcp-port, --mode, and MCP_SCENARIO are supported only for `launch mcp`" )); } + +// ---------------------------------------------------------------------------- +// MCP WS-mode (mcpMode=ws) integration tests +// ---------------------------------------------------------------------------- + +fn setup_mcp_project_with_logging_thin() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = temp_workspace(); + let base_path = dir.path().join("project"); + let work_path = dir.path().join("work"); + let install_dir = dir.path().join("platform"); + let config_path = dir.path().join("v8project.yaml"); + let args_log = install_dir.join("mcp.args.log"); + + fs::create_dir_all(&base_path).expect("base"); + fs::create_dir_all(&work_path).expect("work"); + write_logging_script(&install_dir.join("bin").join("1cv8c"), &args_log); + write_script(&install_dir.join("bin").join("1cv8")); + write_config(&config_path, &base_path, &work_path, &install_dir, None); + + (dir, config_path, args_log) +} + +#[test] +fn launch_mcp_transport_emits_runmcp_payload_and_mcp_envelope() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "--mcp-transport", + "mcp", + "--mcp-port", + "9999", + ]) + .output() + .expect("run command"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["transport"], "mcp"); + assert_eq!(payload["data"]["mcp_port"], 9999); + assert!(payload["data"]["client_uid"].is_null()); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/C\nrunMcp;mcpPort=9999\n")); + assert!(!args.contains("mcpMode=ws")); +} + +#[test] +fn launch_mcp_ws_transport_with_listener_emits_ws_payload_and_ws_envelope() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + // Spawn an ephemeral listener; the manager_url points at it so the probe + // succeeds and v8-runner picks the WS branch. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let manager_url = format!( + "ws://127.0.0.1:{}/sessions", + listener.local_addr().expect("addr").port() + ); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + "--mcp-log-level", + "debug", + "--mcp-ws-timeout-ms", + "2500", + "--client-uid", + "00000000-0000-0000-0000-000000000abc", + "--corr-id", + "vr-deadbeef", + ]) + .output() + .expect("run command"); + drop(listener); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["transport"], "ws"); + assert_eq!( + payload["data"]["client_uid"], + "00000000-0000-0000-0000-000000000abc" + ); + assert_eq!(payload["data"]["kind"], "v8_runner_client"); + assert_eq!(payload["data"]["manager_url"], manager_url); + assert_eq!(payload["data"]["corr_id"], "vr-deadbeef"); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("mcpMode=ws")); + assert!(args.contains("client_uid=00000000-0000-0000-0000-000000000abc")); + assert!(args.contains("kind=v8_runner_client")); + assert!(args.contains(&format!("manager_url={manager_url}"))); + assert!(args.contains("corr_id=vr-deadbeef")); + assert!(args.contains("mcp_log_level=debug")); + assert!(args.contains("mcp_ws_timeout_ms=2500")); + assert!(!args.contains("runMcp")); +} + +#[test] +fn launch_thin_ws_transport_emits_ws_payload_and_ws_envelope() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let manager_url = format!( + "ws://127.0.0.1:{}/sessions", + listener.local_addr().expect("addr").port() + ); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "thin", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + "--mcp-log-level", + "debug", + "--mcp-ws-timeout-ms", + "2500", + "--client-uid", + "00000000-0000-0000-0000-000000000def", + "--corr-id", + "vr-thin", + ]) + .output() + .expect("run command"); + drop(listener); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["mode"], "thin"); + assert_eq!(payload["data"]["transport"], "ws"); + assert_eq!( + payload["data"]["client_uid"], + "00000000-0000-0000-0000-000000000def" + ); + assert!(payload["data"]["kind"].is_null()); + assert_eq!(payload["data"]["manager_url"], manager_url); + assert_eq!(payload["data"]["corr_id"], "vr-thin"); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/C\nmcpMode=ws;")); + assert!(args.contains("client_uid=00000000-0000-0000-0000-000000000def")); + assert!(!args.contains("kind=")); + assert!(args.contains(&format!("manager_url={manager_url}"))); + assert!(args.contains("corr_id=vr-thin")); + assert!(args.contains("mcp_log_level=debug")); + assert!(args.contains("mcp_ws_timeout_ms=2500")); + assert!(!args.contains("runMcp")); +} + +#[test] +fn launch_thin_ws_transport_appends_to_existing_c_payload() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let manager_url = format!( + "ws://127.0.0.1:{}/sessions", + listener.local_addr().expect("addr").port() + ); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "launch", + "thin", + "--c", + "DoWork=1", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + "--client-uid", + "00000000-0000-0000-0000-000000000123", + ]) + .output() + .expect("run command"); + drop(listener); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/C\nDoWork=1;mcpMode=ws;")); + assert!(args.contains("client_uid=00000000-0000-0000-0000-000000000123")); + assert!(!args.contains("kind=")); +} + +#[test] +fn launch_mcp_ws_required_fails_when_manager_unreachable() { + let (_dir, config_path, _args_log) = setup_mcp_project_with_logging_thin(); + // Bind & immediately drop to grab a guaranteed-free port. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + let manager_url = format!("ws://127.0.0.1:{port}/sessions"); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + ]) + .output() + .expect("run command"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stderr}\n{stdout}"); + assert!( + combined.contains("session-manager unreachable") || combined.contains("unreachable"), + "expected 'unreachable' diagnostic, got stderr={stderr}, stdout={stdout}" + ); +} + +#[test] +fn launch_mcp_auto_falls_back_to_mcp_when_manager_unreachable() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + let manager_url = format!("ws://127.0.0.1:{port}/sessions"); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "--mcp-transport", + "auto", + "--manager-url", + &manager_url, + ]) + .output() + .expect("run command"); + assert!(output.status.success()); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["transport"], "mcp"); + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/C\nrunMcp\n")); + assert!(!args.contains("mcpMode=ws")); +} + +#[test] +fn launch_mcp_rejects_invalid_manager_url() { + let (_dir, config_path, _args_log) = setup_mcp_project_with_logging_thin(); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "launch", + "mcp", + "--manager-url", + "ws://bare-host-no-port/sessions", + ]) + .output() + .expect("run command"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--manager-url")); +} + +#[test] +fn launch_mcp_rejects_zero_ws_timeout() { + let (_dir, config_path, _args_log) = setup_mcp_project_with_logging_thin(); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "launch", + "mcp", + "--mcp-ws-timeout-ms", + "0", + ]) + .output() + .expect("run command"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("--mcp-ws-timeout-ms must be greater than or equal to 1")); +} diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 51b08f4..60b12f6 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -302,7 +302,7 @@ fn setup_va_project_with_work_name( ) }; let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\n ignore_tags:\n - '@draft'\n scenario_filter:\n - Проверка логина\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", + "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\n ignore_tags:\n - '@draft'\n scenario_filter:\n - Проверка логина\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n va:\n epf_path: '{}'\n client_mcp:\n transport: mcp\n platform:\n path: '{}'\n{}", base_path.display(), work_path.display(), va_params.display(), @@ -757,6 +757,7 @@ fn test_va_builds_vanessa_command_and_overlay() { assert!(calls.contains("vanessa-automation.epf")); assert!(calls.contains("StartFeaturePlayer;VAParams=")); assert!(calls.contains("/TESTMANAGER")); + assert!(calls.contains("/DisableUnsafeActionProtection")); assert!(calls.contains("/VAUSER")); assert!(calls.contains("ci-user")); @@ -899,9 +900,11 @@ fn test_va_rejects_semicolon_in_generated_params_path() { } #[test] -fn test_va_does_not_duplicate_explicit_testmanager_raw_key() { - let (_dir, config_path, _build_calls, test_calls, _captured_params) = - setup_va_project(JUNIT_SMOKE_REPORT_FIXTURE, &["/TESTMANAGER"]); +fn test_va_does_not_duplicate_explicit_vanessa_raw_keys() { + let (_dir, config_path, _build_calls, test_calls, _captured_params) = setup_va_project( + JUNIT_SMOKE_REPORT_FIXTURE, + &["/TESTMANAGER", "/DisableUnsafeActionProtection"], + ); let output = v8_runner_command() .args([ @@ -910,6 +913,8 @@ fn test_va_does_not_duplicate_explicit_testmanager_raw_key() { "test", "--raw-key", "/TESTMANAGER", + "--raw-key", + "/DisableUnsafeActionProtection", "va", ]) .output() @@ -929,6 +934,11 @@ fn test_va_does_not_duplicate_explicit_testmanager_raw_key() { .filter(|arg| arg.eq_ignore_ascii_case("/TESTMANAGER")) .count(); assert_eq!(test_manager_count, 1); + let unsafe_action_protection_count = calls + .split_whitespace() + .filter(|arg| arg.eq_ignore_ascii_case("/DisableUnsafeActionProtection")) + .count(); + assert_eq!(unsafe_action_protection_count, 1); } #[test] @@ -961,6 +971,7 @@ fn test_va_adds_testmanager_when_raw_value_matches_name() { let calls = fs::read_to_string(test_calls).expect("test calls"); assert!(calls.contains("/VAUser")); assert!(calls.contains("TESTMANAGER")); + assert!(calls.contains("/DisableUnsafeActionProtection")); assert!(calls .split_whitespace() .any(|arg| arg.eq_ignore_ascii_case("/TESTMANAGER"))); diff --git a/SKILL/SKILL.md b/v8-runner/SKILL.md similarity index 63% rename from SKILL/SKILL.md rename to v8-runner/SKILL.md index 33b1b03..2f11fc4 100644 --- a/SKILL/SKILL.md +++ b/v8-runner/SKILL.md @@ -10,6 +10,7 @@ Use this skill to operate `v8-runner` as the automation layer for local 1C devel Keep this file as the decision entrypoint. Load only the reference file that matches the task: - `references/command-selection.md` for choosing the right command sequence. +- `references/bootstrap.md` for generating `v8project.yaml` from an existing repository — what to detect yourself and what to ask the user (decision tree for `format`, `builder`, `connection`). - `references/config-and-backends.md` for `v8project.yaml`, source sets, formats, builders, and backend limits. - `references/project-workflows.md` for common build, syntax, dump, launch, and source sync workflows across Designer and EDT projects. - `references/file-and-artifact-workflows.md` for dump, convert, load, make/artifacts, and staged publication. @@ -66,7 +67,13 @@ v8-runner init - Branch switch, rebase, large object moves, stale source-backed tool extension state, or suspicious incremental state: run `v8-runner build --full-rebuild`. - Syntax check: inspect `format` and `builder`, then choose `syntax designer-modules`, `syntax designer-config`, or `syntax edt`. - Behavior validation: run the relevant `v8-runner test ...` command; tests build first. -- Vanessa Automation debugging or scenario authoring: use `v8-runner launch mcp va ...` to start the client MCP server with VA loaded. +- Vanessa Automation debugging or scenario authoring: use `v8-runner launch mcp va ...` + to start the VA test-manager for MCP tools. The runner adds `/TESTMANAGER` and + `/DisableUnsafeActionProtection`; WS mode passes `mcpMode=ws;...;VAParams=` + in `/C`, while local HTTP MCP fallback passes `runMcp...;VAParams=` + without `StartFeaturePlayer`. Transport defaults to `auto`: WS is selected when the + session-manager is reachable, while local HTTP MCP is selected only with + `--mcp-transport=mcp` or as the `auto` fallback. - Extension properties need synchronization: use `v8-runner extensions` or `extensions --name `. - Infobase changes need to become Git-visible files: check `git status`, then run the relevant `v8-runner dump ...` command. - Source files need conversion between Designer and EDT: use `v8-runner convert`; this is CLI-only and does not use the infobase. @@ -74,6 +81,26 @@ v8-runner init - Release artifacts need to be exported or external artifacts published: use `v8-runner make ...` or the `artifacts` alias. - Need a 1C UI session: use `v8-runner launch designer`, `launch thin`, `launch thick`, or `launch ordinary`. - Need onec-client-mcp-devkit launched inside 1C without VA authoring: use `v8-runner launch mcp ...`. +- Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or use local HTTP MCP with `--mcp-transport=mcp`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. + +## Launch Modes + +| Mode | Purpose | MCP/VA behavior | +|---|---|---| +| `launch designer` | Open Designer. | No client MCP tools; enterprise additional keys are not applied. | +| `launch thin`, `launch thick`, `launch ordinary` | Open a regular 1C UI client. | Optional WS base client tools; no VA tools by itself. | +| `launch mcp` | Start onec-client-mcp-devkit inside 1C without Vanessa. | `kind=v8_runner_client` in WS, or local HTTP MCP with `--mcp-transport=mcp` / `auto` fallback. | +| `launch mcp va` | Start the Vanessa test-manager for research, authoring, and VA client MCP tools. | `kind=vanessa_test_client`; runner adds `/TESTMANAGER`, `/DisableUnsafeActionProtection`, `/Execute `; WS mode uses `mcpMode=ws;...;VAParams=` in `/C`, local MCP fallback uses `runMcp...;VAParams=`, and neither mode uses `StartFeaturePlayer`. | +| `test yaxunit ...` | Run YAxUnit tests. | `kind=yaxunit_runner` in WS; not an interactive UI session. | +| `test va` | Run Vanessa feature scenarios. | `kind=vanessa_test_client`, but payload is `StartFeaturePlayer;VAParams=...`; this is scenario execution, not the manager research mode. | + +For `launch mcp va --mcp-transport=ws`, wait for the `kind=vanessa_test_client` +session in `v8-client-session-manager`: normal startup/tool registration takes 10-90 seconds. +Poll `session_list` every 5-10 seconds and use 120 seconds as a diagnostic limit. After the +VA tools appear, call `connect_test_client` with the required `profileName` argument; then the VA +client MCP tools become available. After the investigation or on error, call `close_test_client` +with the same `profileName` (or without it to close the current connected profile) so extra 1C +test-client sessions are not kept alive. ## Guardrails diff --git a/SKILL/agents/openai.yaml b/v8-runner/agents/openai.yaml similarity index 100% rename from SKILL/agents/openai.yaml rename to v8-runner/agents/openai.yaml diff --git a/v8-runner/references/bootstrap.md b/v8-runner/references/bootstrap.md new file mode 100644 index 0000000..557659b --- /dev/null +++ b/v8-runner/references/bootstrap.md @@ -0,0 +1,126 @@ +# Bootstrapping `v8project.yaml` + +Use this reference when starting `v8-runner` work in a 1С repository that has no `v8project.yaml` yet. The goal is to ask the user **only the questions you cannot answer yourself by inspecting the repository**. + +`v8-runner config init` already auto-detects: +- existing source sets (Designer and EDT) under the project root, +- chosen format when `--format=auto` (default), based on what was found, +- platform version when discoverable from sources. + +So in most projects the right command is simply: + +```bash +v8-runner config init +``` + +Inspect the generated `v8project.yaml` afterwards and only re-run with explicit flags if the auto-detection was wrong or the user has constraints not visible from the filesystem. + +## Decision tree (run this BEFORE asking the user) + +### 1. Check whether the project is already configured + +If `v8project.yaml` exists — do **not** run `config init` without `--force`. Inspect it instead and report what is configured. Reaching for `--force` requires the user’s explicit consent because it overwrites whatever local tweaks they had. + +### 2. Determine source format from the filesystem + +Look at the project root and immediate subdirectories. + +| Filesystem signal | Likely format | +|---|---| +| `src/cf/Configuration.xml`, `src/cfe//Configuration.xml`, raw Designer XML tree | `DESIGNER` | +| `src/cf/.project`, `src/cf/Configuration/Configuration.mdo`, `*.mdo`, `DT-INF/` | `EDT` | +| Both kinds of trees side by side (mixed mono-repo) | `auto` (default) — let `config init` register both source sets | +| Neither of the above | the repository is not a 1С source tree — stop and ask the user what they expect to find | + +If the format is unambiguously one or the other, you may pass `--format=designer` or `--format=edt` for clarity, but do not have to — `auto` will pick the same. + +### 3. Decide the builder backend + +Defaults to `DESIGNER`. Switch to `IBCMD` only when: + +- the project is `EDT` and the team is on a platform version where `ibcmd` is supported and faster (≥ 8.3.20); **and** +- there is no Designer-only feature on the critical path (some older project tasks still need the Designer GUI). + +Ask the user only if the choice is ambiguous and you cannot tell from `tools.platform.version`. Default to `DESIGNER` when in doubt — it is the safer baseline. + +### 4. Decide infobase connection + +`--connection` is the connection string written into `infobase.connection`. Three common shapes: + +| Shape | Example | When to use | +|---|---|---| +| File infobase auto-managed by `v8-runner` | `--connection "File=build/ib"` | most local dev cycles. Path is created on first `v8-runner init`. Safe default. | +| File infobase that already exists on disk | `--connection "File=/abs/path/to/ib"` | the user has an existing baseline they want to point at | +| Server infobase | `--connection "Srvr=cluster:1541;Ref=ibname"` | central dev base, shared infobase, CI runner attached to a cluster | + +For server connections there is no auto-creation (it requires DBA-level operations). The user has to confirm the database exists and credentials are stored elsewhere (`v8project.local.yaml` keeps secrets out of git). + +If the project README, `docker-compose.yml`, or `.env` already describes a connection, **use that** without re-asking. If nothing is documented, ask the user **once** with the three options above and a default of `File=build/ib`. + +### 5. Decide output path + +Default `./v8project.yaml`. Override via `--output` only when the project is a sub-tree inside a larger repo and the user explicitly wants a non-root config. Do not invent `--output` values. + +## When to ask the user (and how) + +Ask only when at least one of these is true: + +1. **The repository looks ambiguous or empty** — no Designer/EDT signals and no other clue. Ask: «I don’t see a Designer or EDT source tree under ``. Where do the 1С sources live, or should I create a fresh empty config?» +2. **A server infobase is hinted at, but no connection string is documented.** Ask: «This looks like a server-bound project. What is the cluster:port and infobase name? Or should I default to a local `File=build/ib`?» +3. **Mixed Designer + EDT sources are detected** and the user clearly works with only one of them. Ask: «I detected both Designer and EDT trees. Should I register both as separate source sets, or only one? Which one is the primary?» +4. **`tools.platform.version` cannot be inferred** and the user has multiple installed. Ask once. + +Phrase questions in a single round, give a default, and proceed if the user wants to keep defaults. + +## Sample interactions + +### Generic File-base project, Designer sources + +```bash +v8-runner config init --connection "File=build/ib" +v8-runner init # creates the file infobase +v8-runner build # applies sources +``` + +No questions to the user. + +### EDT project on platform 8.3.20+ + +```bash +v8-runner config init --format edt --builder IBCMD +v8-runner init +v8-runner build +``` + +No questions if the platform version is detected. + +### Mixed Designer + EDT mono-repo + +```bash +v8-runner config init # registers both source sets via auto +``` + +Ask only if the user wants only one of them to be active. + +### Server-bound project + +```bash +# After confirming with the user: +v8-runner config init --connection "Srvr=10.0.0.10:1541;Ref=dssl_drive_ai" +# v8-runner init is NOT run — the database is managed externally +v8-runner build # applies local sources to the existing infobase +``` + +Ask once for `Srvr=...;Ref=...` and credentials policy (where are they stored). Server-bound config typically lives outside git via `v8project.local.yaml`. + +## After `config init` + +Always inspect the generated `v8project.yaml` before running mutating commands. Show the user: + +- the picked format and builder (`format`, `builder`), +- detected source sets (`source-sets[*].name`, `path`), +- the connection string, +- whether `tools.platform.version` was filled, +- any warnings printed by `config init` (often hint at missing tools or unusual layouts). + +If something looks off, reach for explicit `--format`, `--builder`, or `--connection` rather than editing the YAML by hand — that keeps the workflow reproducible. diff --git a/SKILL/references/command-selection.md b/v8-runner/references/command-selection.md similarity index 82% rename from SKILL/references/command-selection.md rename to v8-runner/references/command-selection.md index 3ca894c..a989f5b 100644 --- a/SKILL/references/command-selection.md +++ b/v8-runner/references/command-selection.md @@ -161,3 +161,13 @@ v8-runner launch mcp v8-runner launch mcp --mode thin --mcp-port v8-runner launch mcp --mcp-config ``` + +WS-mode flags (when v8-client-session-manager is reachable): + +```bash +v8-runner launch mcp --mcp-transport=ws --manager-url ws://127.0.0.1:4000/sessions +v8-runner launch mcp --mcp-transport=mcp # force local MCP without probe +v8-runner launch mcp --mcp-log-level=debug --client-uid --corr-id +``` + +`--mcp-transport=auto` (default) probes `manager_url` for 200 ms and chooses `ws` on success, `mcp` on failure. The same WS-flags work on `test yaxunit ...` and `test va ...`. See `project-workflows.md` for the full WS-режим section, internal `kind` mapping, and `--json-message` output shape. diff --git a/SKILL/references/config-and-backends.md b/v8-runner/references/config-and-backends.md similarity index 100% rename from SKILL/references/config-and-backends.md rename to v8-runner/references/config-and-backends.md diff --git a/SKILL/references/file-and-artifact-workflows.md b/v8-runner/references/file-and-artifact-workflows.md similarity index 100% rename from SKILL/references/file-and-artifact-workflows.md rename to v8-runner/references/file-and-artifact-workflows.md diff --git a/v8-runner/references/project-workflows.md b/v8-runner/references/project-workflows.md new file mode 100644 index 0000000..513236c --- /dev/null +++ b/v8-runner/references/project-workflows.md @@ -0,0 +1,199 @@ +# Project Workflows + +Use these flows by user intent. Do not split the workflow only because source files are Designer or EDT; many commands share the same lifecycle and differ only by `format`, `builder`, or tool availability. + +For exact support rules, read `config-and-backends.md` together with this file. + +## Bootstrap + +Create the default config when the project has no `v8project.yaml`: + +```bash +v8-runner config init +``` + +Choose a narrower init command only when the project shape is known: + +```bash +v8-runner config init --connection "File=build/ib" +v8-runner config init --format edt +v8-runner config init --builder IBCMD +``` + +Initialize generated runtime state only when the file infobase or EDT workspace needs to be created: + +```bash +v8-runner init +``` + +## Build + +Apply Git-visible source changes to the configured runtime state: + +```bash +v8-runner build +``` + +Use a full rebuild after branch switches, rebases, broad object moves, or suspicious incremental state: + +```bash +v8-runner build --full-rebuild +``` + +`build` is a common workflow. For EDT projects it may export EDT sources to Designer files before applying them through the configured backend. For Designer projects it applies Designer sources directly through the configured backend. + +If `tools.client_mcp.extension` is configured, `build` also prepares that tool extension after the project source-set stage, including scoped `--source-set` builds. Source-backed tool extensions use their own change-detection state and are skipped when unchanged; use `build --full-rebuild` to force refresh. Do not add a tool extension as a project `source-set` or select it with `--source-set`. + +## Syntax + +Choose syntax checks from config capabilities, not from assumptions about the repository name. + +Designer module checks: + +```bash +v8-runner build +v8-runner syntax designer-modules --server --thin-client +``` + +Designer configuration checks: + +```bash +v8-runner build +v8-runner syntax designer-config +``` + +EDT checks: + +```bash +v8-runner build +v8-runner syntax edt +``` + +If a syntax command is unavailable for the current `format` or `builder`, report the config limitation instead of inventing raw platform commands. + +## Dump + +Use dump when the desired source of truth is the current infobase state. + +Before dumping, inspect current Git changes: + +```bash +git status --short +``` + +Incremental dump: + +```bash +v8-runner dump --mode incremental +``` + +Partial object dump when the backend supports it: + +```bash +v8-runner dump --mode partial --object +``` + +Run `git diff` after dump and report the affected files. + +## Extensions + +Use `extensions` when extension properties need to be synchronized without a broader recovery step. + +Do not replace extension-specific synchronization with a full rebuild unless the user asks for recovery or the narrower command fails for a relevant reason. + +```bash +v8-runner extensions +v8-runner extensions --name +``` + +## Launch + +Prefer runner launch commands over raw `1cv8` command construction: + +```bash +v8-runner launch designer +v8-runner launch thin +v8-runner launch thick +v8-runner launch ordinary +``` + +Launch onec-client-mcp-devkit through the supported `launch mcp` surface instead of manually assembling `/C"runMcp..."`: + +```bash +v8-runner launch mcp +v8-runner launch mcp --mode thin --mcp-port +v8-runner launch mcp --mcp-config +``` + +For ordinary direct launches, typed launch flags include `--c`, `--execute`, `--use-privileged-mode`, `--output`, and repeatable `--raw-key`. + +For `launch mcp`, use `--mcp-config` and `--mcp-port`; do not pass `/C` through `--c`. + +`launch mcp` and `launch mcp va` do not install or update `tools.client_mcp.extension`; run `v8-runner build` first when that extension may be missing or stale. + +For `launch mcp va`, read `testing.md`; it is part of the Vanessa Automation debugging and scenario-authoring workflow. + +## WS-режим к session-manager + +Когда рядом с проектом запущен [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager), 1С-клиент может подключаться к нему по WebSocket вместо локального HTTP MCP-сервера (`runMcp`-режим). v8-runner делает выбор автоматически. + +### Транспорт и автоопределение + +`tools.client_mcp.transport`: + +- `auto` (по умолчанию) — короткий TCP-probe (200 ms) на IP:порт из `manager_url`. Слышим listener → WS, нет → MCP. +- `ws` — строго WS, при недоступности менеджера запуск падает с `session-manager unreachable at `. +- `mcp` — локальный HTTP MCP-режим без probe. + +Override через `--mcp-transport={ws|mcp|auto}`. CLI приоритет конфига. + +### Что v8-runner подставляет в `/C` в WS-ветке + +```text +/C"mcpMode=ws;manager_url=;client_uid=;kind=;corr_id=;mcp_log_level=;mcp_ws_timeout_ms=" +``` + +Источники значений: + +| Ключ | По умолчанию | Override | +|------|--------------|----------| +| `manager_url` | `tools.client_mcp.manager_url` или `ws://127.0.0.1:4000/sessions` | `--manager-url ` | +| `client_uid` | новый UUID v4 на каждый запуск | `--client-uid ` | +| `kind` | внутренний mapping (см. таблицу ниже) | (нет — kind не переопределяется из CLI) | +| `corr_id` | `vr-<первые 8 символов client_uid>` | `--corr-id ` | +| `mcp_log_level` | `tools.client_mcp.log_level` или `info` | `--mcp-log-level={off\|error\|warn\|info\|debug\|trace}` | +| `mcp_ws_timeout_ms` | `tools.client_mcp.ws_timeout_ms` или `1000` | `--mcp-ws-timeout-ms ` | + +### Internal `kind` mapping + +| Команда v8-runner | `kind` | +|---|---| +| `launch mcp` | `v8_runner_client` | +| `launch mcp va` | `vanessa_test_client` | +| `test yaxunit ...` | `yaxunit_runner` | +| `test va ...` | `vanessa_test_client` | + +`vanessa_test_client` имеет специальную семантику в менеджере: его прокси-тулы публикуются на MCP HTTP без префикса `__` (см. router в репозитории менеджера). Не подменяй `kind` вручную. + +### Тестовые подкоманды (`test yaxunit`, `test va`) + +Для тестовых запусков WS-фрагмент **дописывается** через `;` к существующему `/C` (`RunUnitTests=…` или Vanessa-плеер). Никаких отдельных флагов прописывать не надо — те же `--mcp-transport`/`--manager-url`/`--mcp-log-level` доступны и тут. + +### JSON-output + +В режиме `--json-message` ответ launch- и test-команд включает поля транспорта: + +WS-ветка: +```json +{ "transport": "ws", "client_uid": "...", "kind": "...", "manager_url": "...", "corr_id": "..." } +``` +MCP-ветка: +```json +{ "transport": "mcp", "mcp_port": 9874 } +``` + +Внешний оркестратор (CI, AI-агент) использует `client_uid` для поиска сессии в `session_list` менеджера. + +### Менеджер не запускается из v8-runner + +v8-runner только подключается к запущенному менеджеру. Подъём менеджера — отдельный шаг (`cargo run --release` в репо `v8-client-session-manager`, либо systemd-юнит `systemd/v8-session-manager.service`, либо Docker-compose). Если менеджер не нужен — `--mcp-transport=mcp` форсирует локальный MCP flow. diff --git a/SKILL/references/testing.md b/v8-runner/references/testing.md similarity index 100% rename from SKILL/references/testing.md rename to v8-runner/references/testing.md diff --git a/SKILL/references/troubleshooting.md b/v8-runner/references/troubleshooting.md similarity index 100% rename from SKILL/references/troubleshooting.md rename to v8-runner/references/troubleshooting.md