diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 3af7f61..0ad5bf1 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -68,7 +68,9 @@ v8-runner init - Only one source-set changed: use commands that accept `--source-set ` instead of rebuilding or materializing everything. - 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. +- Behavior validation: run the relevant `v8-runner test ...` command; tests build first with a + static `/UpdateDBCfg`. Use `v8-runner build --dynamic` before tests when dynamic preparation is + intentional. - Missing local YAxUnit, Vanessa Automation, or onec-client-mcp-devkit setup: run `v8-runner tools download yaxunit --sources`, `v8-runner tools download vanessa`, and `v8-runner tools download client-mcp --sources` for source-backed setup. Omit diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index f9d67c9..3d9e512 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -10,6 +10,8 @@ settings before CLI overrides. - `format`: `DESIGNER` or `EDT`. - `builder`: `DESIGNER` or `IBCMD`. - `infobase.connection`: often `File=build/ib` for local automation. +- `infobase.unlock_code`: optional infobase locking code. Non-empty values are propagated to DESIGNER as `/UC `; an empty string omits `/UC`. Required when the configuration was sealed with "Установить пароль"; masked in logs. Place this in `v8project.local.yaml` together with `infobase.password`. +- `build.dynamicUpdate`: project-wide default for `/UpdateDBCfg -Dynamic+`. Off by default. CLI `build --dynamic` overrides it for a single invocation. - `source-set`: ordered configuration and extension sources. - `tools.platform.path` or `tools.platform.version`: 1C platform discovery hints. - `tools.edt_cli.path`, `version`, and `interactive-mode`: EDT CLI discovery and execution mode. diff --git a/SKILL/references/project-workflows.md b/SKILL/references/project-workflows.md index 76cc98a..83bbe28 100644 --- a/SKILL/references/project-workflows.md +++ b/SKILL/references/project-workflows.md @@ -43,6 +43,18 @@ Use a full rebuild after branch switches, rebases, broad object moves, or suspic v8-runner build --full-rebuild ``` +Use dynamic update when the infobase has live HTTP services or background jobs that block the +exclusive lock required by the default static `/UpdateDBCfg`: + +```bash +v8-runner build --dynamic +``` + +Equivalent project-wide default is `build.dynamicUpdate: true` in `v8project.yaml`. The CLI +flag overrides the config for one invocation. The platform refuses dynamic mode when the +change set requires restructuring; `v8-runner` surfaces that error verbatim and does not fall +back to a static update. + `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`. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 24befc3..071e538 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -139,10 +139,14 @@ v8-runner extensions [--name ...] ### `build` ```bash -v8-runner build [--source-set ] [--full-rebuild] +v8-runner build [--source-set ] [--full-rebuild] [--dynamic] ``` - Без `--source-set` обрабатывает все configured `source-set` в canonical order. +- `--dynamic` (или `build.dynamicUpdate: true` в `v8project.yaml`) добавляет к + `/UpdateDBCfg` флаг `-Dynamic+`. Платформа применяет изменения без захвата + исключительной блокировки; на изменениях, требующих реструктуризации, DESIGNER возвращает + ошибку — fallback на статический режим не выполняется. - С `--source-set` project stage анализирует и строит только указанный `source-set`; неизвестное имя отклоняется как validation error. - Для `DESIGNER` выбирает incremental, partial или full path по изменённым файлам выбранного scope. @@ -171,7 +175,9 @@ v8-runner test va v8-runner test va --feature login --filter-tag @smoke ``` -- Всегда сначала запускает `build`. +- Всегда сначала запускает `build` со статическим `/UpdateDBCfg`, даже если + `build.dynamicUpdate: true`. Для динамической подготовки перед тестами выполните отдельный + `v8-runner build --dynamic`. - `test yaxunit module ` требует непустое имя модуля. - `test va` использует профиль из `tests.va.profile`; `--feature`, `--filter-tag`, `--ignore-tag` и `--scenario-filter` переопределяют соответствующие списки выбранного профиля @@ -308,7 +314,7 @@ v8-runner mcp serve http | Инструмент | Основные поля запроса | Примечания | | --- | --- | --- | -| `build_project` | `fullRebuild`, `sourceSet` | `fullRebuild=false`; `sourceSet` omitted значит все source-set | +| `build_project` | `fullRebuild`, `sourceSet`, `dynamicUpdate` | `fullRebuild=false`; `sourceSet` omitted значит все source-set; `dynamicUpdate` (опц.) переопределяет `build.dynamicUpdate` для одного вызова | | `run_all_tests` | `full` | Компактный вывод по умолчанию | | `run_module_tests` | `moduleName`, `full` | Отклоняет пустой `moduleName` | | `dump_config` | `mode`, `extension`, `objects` | Пустой `mode` нормализуется в `INCREMENTAL` | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5888148..e0fcbab 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -127,6 +127,7 @@ infobase: connection: "File=build/ib" user: Admin password: secret + unlock_code: seal-42 # non-empty value is passed as `/UC <значение>` source-set: - name: main @@ -138,6 +139,7 @@ source-set: build: partialLoadThreshold: 20 + dynamicUpdate: false # `/UpdateDBCfg -Dynamic+` по умолчанию tools: client_mcp: @@ -314,6 +316,17 @@ tests: Credentials самой информационной базы. +#### `infobase.unlock_code` + +- Тип: строка +- Обязателен: нет + +Кодовое слово (`Конфигурация → Установить пароль`), которое транслируется в DESIGNER как +`/UC <значение>`. Без него платформа отказывается выполнять административные операции на +запароленных конфигурациях. Пустая строка считается отсутствием кода и не добавляет `/UC`. +Значение маскируется в логах команд (`/UC ***`), поэтому его безопасно держать в +`v8project.local.yaml` рядом с `infobase.password`. + #### `infobase.dbms` - Тип: объект @@ -373,6 +386,18 @@ Validation rules: Порог между partial и full load. +#### `build.dynamicUpdate` + +- Тип: boolean +- По умолчанию: `false` + +Включает режим динамического обновления (`/UpdateDBCfg -Dynamic+`) для `build`. Полезно, +когда в инфобазе живут HTTP-сервисы или фоновые задания и захват исключительной блокировки +нежелателен. Если изменения требуют реструктуризации, DESIGNER возвращает ошибку, и +`v8-runner` пробрасывает её наружу — fallback на статический режим не выполняется. + +CLI-флаг `v8-runner build --dynamic` переопределяет это значение на одну команду. + CLI selector `v8-runner build --source-set ` использует `source-set[].name` как stable runtime identity и не добавляет отдельное поле конфигурации. Если selector не задан, `build` обрабатывает все `source-set`. diff --git a/docs/DEEP_DIVE.md b/docs/DEEP_DIVE.md index 9295445..325a0f9 100644 --- a/docs/DEEP_DIVE.md +++ b/docs/DEEP_DIVE.md @@ -66,7 +66,9 @@ runtime snapshot commit только указанным source-set. `test` и `syntax` проектируются как часть того же локального цикла, а не как отдельная эксплуатационная подсистема. -- `test` всегда сначала делает `build`, затем запускает YaXUnit или Vanessa Automation. +- `test` всегда сначала делает `build` со статическим `/UpdateDBCfg`, затем запускает YaXUnit или + Vanessa Automation. Динамическая подготовка перед тестами выполняется отдельным + `build --dynamic`. - `syntax designer-*` работает только для `DESIGNER` source format. - `syntax edt` использует EDT `validate` и привязан к `format=EDT`. - Таймауты и interruption metadata должны проходить через общий command-level contract, а не diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index f2c1ec2..cd495b3 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -242,6 +242,13 @@ "null" ] }, + "unlock_code": { + "description": "Optional local infobase unlock code. Non-empty value is propagated as `/UC `;\nempty string means no unlock code and `/UC` is not passed. Masked in command logs.", + "type": [ + "string", + "null" + ] + }, "user": { "description": "Optional local infobase user name.", "type": [ diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index a96fc4f..c936da3 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -3,6 +3,10 @@ "BuildSchema": { "additionalProperties": false, "properties": { + "dynamicUpdate": { + "description": "Default `/UpdateDBCfg -Dynamic+` toggle for `build`. CLI `--dynamic` overrides this.", + "type": "boolean" + }, "partialLoadThreshold": { "description": "Maximum changed-file count for partial Designer load before falling back to full load.", "format": "uint", @@ -198,6 +202,13 @@ "null" ] }, + "unlock_code": { + "description": "Optional unlock code. Non-empty value is propagated as `/UC ` to DESIGNER;\nempty string means no unlock code and `/UC` is not passed. Masked in command logs.", + "type": [ + "string", + "null" + ] + }, "user": { "description": "Optional infobase user name passed to platform utilities.", "type": [ diff --git a/src/cli/args.rs b/src/cli/args.rs index 3cb78a0..b595e6e 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -169,6 +169,14 @@ pub struct BuildArgs { /// Limit build to one source-set from v8project.yaml #[arg(long)] pub source_set: Option, + + /// Apply changes via `/UpdateDBCfg -Dynamic+` (no exclusive lock). + /// + /// Overrides `build.dynamicUpdate` from v8project.yaml for this run. The platform itself + /// refuses dynamic mode when restructuring is required; the runner surfaces that error + /// instead of falling back to a static update. + #[arg(long)] + pub dynamic: bool, } #[derive(Args, Debug)] diff --git a/src/cli/execute.rs b/src/cli/execute.rs index dd6b05f..222dd23 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -807,6 +807,8 @@ fn map_build_request(args: &BuildArgs) -> BuildRequest { BuildRequest { full_rebuild: args.full_rebuild, source_set: args.source_set.clone(), + // CLI flag is a one-shot override; absence means "fall back to project config". + dynamic_update: if args.dynamic { Some(true) } else { None }, } } @@ -2548,6 +2550,7 @@ mod tests { map_build_request(&BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }) .full_rebuild ); @@ -2829,6 +2832,7 @@ mod tests { command_name(&Command::Build(BuildArgs { full_rebuild: false, source_set: None, + dynamic: false, })), CommandName::Build ); @@ -2899,6 +2903,7 @@ mod tests { &Command::Build(BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }), None, &presenter, @@ -3027,6 +3032,7 @@ mod tests { &Command::Build(BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }), None, &presenter, diff --git a/src/config/loader.rs b/src/config/loader.rs index 6c61089..a04b865 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -715,6 +715,36 @@ mod tests { assert_eq!(config.build.partial_load_threshold, 7); } + #[test] + fn load_config_reads_build_dynamic_update_and_infobase_unlock_code() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let src = base.join("src"); + std::fs::create_dir_all(&src).expect("src dir"); + let config_path = dir.path().join("v8project.yaml"); + std::fs::write( + &config_path, + format!( + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n unlock_code: seal-1\nbuild:\n dynamicUpdate: true\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", + work.display() + ), + ) + .expect("write config"); + + let config = load_config(config_path.to_str(), None).expect("load config"); + + assert!(config.build.dynamic_update); + assert_eq!(config.infobase.unlock_code.as_deref(), Some("seal-1")); + + // And the resulting V8Connection carries the unlock code into the platform layer. + let connection = config.v8_connection(); + assert_eq!(connection.unlock_code.as_deref(), Some("seal-1")); + let args = connection.args(); + let uc_index = args.iter().position(|arg| arg == "/UC").expect("/UC"); + assert_eq!(args.get(uc_index + 1).map(String::as_str), Some("seal-1")); + } + #[test] fn load_config_reads_test_timeout_from_exact_yaml_key() { let dir = tempdir().expect("tempdir"); diff --git a/src/config/model.rs b/src/config/model.rs index c986256..a53e6ef 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -66,6 +66,14 @@ pub struct InfobaseConfig { /// Optional infobase password passed to platform utilities. pub password: Option, + /// Optional infobase unlock code propagated to DESIGNER calls as `/UC `. + /// + /// Required by configurations sealed with `Конфигурация → Установить пароль`; without the + /// matching code the platform refuses every administrative operation. The value is treated + /// as a secret and masked in command logs. + #[serde(default)] + pub unlock_code: Option, + /// Optional DBMS contract for server-based infobases. #[serde(default)] pub dbms: Option, @@ -79,6 +87,7 @@ impl InfobaseConfig { connection: connection.into(), user: None, password: None, + unlock_code: None, dbms: None, } } @@ -98,6 +107,7 @@ impl InfobaseConfig { connection: connection.into(), user: None, password: None, + unlock_code: None, dbms: Some(dbms), } } @@ -159,6 +169,7 @@ impl AppConfig { let mut conn = V8Connection::from_connection_string(&self.infobase.connection); conn.user = self.infobase.user.clone(); conn.password = self.infobase.password.clone(); + conn.unlock_code = self.infobase.unlock_code.clone(); conn } @@ -226,12 +237,23 @@ impl SourceSetPurpose { pub struct BuildConfig { #[serde(default = "default_partial_load_threshold")] pub partial_load_threshold: usize, + + /// Default mode for `/UpdateDBCfg` during `build`. + /// + /// When `true`, DESIGNER is invoked with `-Dynamic+`, which lets the platform apply + /// metadata changes without taking an exclusive infobase lock (useful when HTTP services + /// or background jobs are live). If the change set is incompatible with dynamic update + /// (e.g. restructuring), DESIGNER returns an error — the runner does NOT silently fall + /// back to a static update. CLI `--dynamic` overrides this field for a single invocation. + #[serde(default)] + pub dynamic_update: bool, } impl Default for BuildConfig { fn default() -> Self { Self { partial_load_threshold: default_partial_load_threshold(), + dynamic_update: false, } } } diff --git a/src/config/schema.rs b/src/config/schema.rs index 4796d4b..e2fb0af 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -441,6 +441,10 @@ struct InfobaseSchema { /// Optional infobase password passed to platform utilities. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, + /// Optional unlock code. Non-empty value is propagated as `/UC ` to DESIGNER; + /// empty string means no unlock code and `/UC` is not passed. Masked in command logs. + #[serde(default, skip_serializing_if = "Option::is_none")] + unlock_code: Option, /// Optional DBMS settings for server-based infobases. #[serde(default, skip_serializing_if = "Option::is_none")] dbms: Option, @@ -463,6 +467,10 @@ struct PartialInfobaseSchema { /// Optional local infobase password. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, + /// Optional local infobase unlock code. Non-empty value is propagated as `/UC `; + /// empty string means no unlock code and `/UC` is not passed. Masked in command logs. + #[serde(default, skip_serializing_if = "Option::is_none")] + unlock_code: Option, /// Optional local DBMS settings override. #[serde(default, skip_serializing_if = "Option::is_none")] dbms: Option, @@ -522,6 +530,14 @@ struct BuildSchema { )] #[schemars(with = "usize")] partial_load_threshold: Option, + /// Default `/UpdateDBCfg -Dynamic+` toggle for `build`. CLI `--dynamic` overrides this. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "bool")] + dynamic_update: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] diff --git a/src/config/validate.rs b/src/config/validate.rs index cd8e0e4..7385fa9 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -1335,6 +1335,7 @@ mod tests { }], build: BuildConfig { partial_load_threshold: 0, + dynamic_update: false, }, tools: ToolsConfig::default(), mcp: Default::default(), @@ -2150,6 +2151,7 @@ mod tests { connection: "Srvr=localhost;Ref=ib".to_owned(), user: None, password: None, + unlock_code: None, dbms: None, }, source_sets: vec![SourceSetConfig { diff --git a/src/mcp/port.rs b/src/mcp/port.rs index 2e8db54..54cf0bc 100644 --- a/src/mcp/port.rs +++ b/src/mcp/port.rs @@ -231,6 +231,7 @@ mod tests { &BuildRequest { full_rebuild: true, source_set: None, + dynamic_update: None, }, ) .expect_err("busy workspace"); diff --git a/src/mcp/request.rs b/src/mcp/request.rs index 05130d7..8948876 100644 --- a/src/mcp/request.rs +++ b/src/mcp/request.rs @@ -11,6 +11,11 @@ pub struct McpBuildProjectRequest { /// Optional source-set selector from v8project.yaml. #[schemars(description = "Source-set name to build. When omitted, all source-sets are built.")] pub source_set: Option, + /// Optional one-shot override for `/UpdateDBCfg -Dynamic+`. + #[schemars( + description = "Override build.dynamicUpdate for this call: true applies changes without exclusive lock." + )] + pub dynamic_update: Option, } /// MCP request for `run_all_tests`. diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 4a7d003..8dca840 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -59,6 +59,7 @@ where let use_case_request = BuildRequest { full_rebuild: request.full_rebuild.unwrap_or(false), source_set: request.source_set.clone(), + dynamic_update: request.dynamic_update, }; match self @@ -823,6 +824,7 @@ mod tests { &McpBuildProjectRequest { full_rebuild: Some(true), source_set: Some("main".to_owned()), + dynamic_update: Some(true), }, ) .expect("success"); @@ -838,6 +840,7 @@ mod tests { assert_eq!(requests[0].0.transport(), ExecutionTransport::McpStdio); assert_eq!(requests[0].1.full_rebuild, true); assert_eq!(requests[0].1.source_set.as_deref(), Some("main")); + assert_eq!(requests[0].1.dynamic_update, Some(true)); } #[test] diff --git a/src/platform/connection.rs b/src/platform/connection.rs index c4880fa..10a7591 100644 --- a/src/platform/connection.rs +++ b/src/platform/connection.rs @@ -7,6 +7,12 @@ pub struct V8Connection { pub user: Option, /// Optional password added as `/P `. pub password: Option, + /// Optional infobase unlock code emitted as `/UC `. + /// + /// Configurations protected by a locking code (`Конфигурация → Установить пароль`) refuse any + /// administrative DESIGNER operation until the matching `/UC` is supplied; an empty string is + /// treated as "no unlock code" for backwards compatibility with explicit nulling in overlays. + pub unlock_code: Option, } impl V8Connection { @@ -24,6 +30,7 @@ impl V8Connection { connection_args, user: None, password: None, + unlock_code: None, } } @@ -40,6 +47,14 @@ impl V8Connection { args.push(password.clone()); } } + if let Some(unlock_code) = &self.unlock_code { + // Empty value is intentionally treated as absent: a misconfigured overlay setting + // `unlock_code: ""` should not push an `/UC` token without a value to the platform. + if !unlock_code.is_empty() { + args.push("/UC".to_owned()); + args.push(unlock_code.clone()); + } + } args } @@ -156,4 +171,41 @@ mod tests { assert_eq!(connection.args(), vec!["/F", "/tmp/ib"]); assert_eq!(connection.file_path(), Some("/tmp/ib")); } + + #[test] + fn appends_unlock_code_after_credentials() { + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.user = Some("Admin".to_owned()); + connection.password = Some("pw".to_owned()); + connection.unlock_code = Some("uc-secret".to_owned()); + + assert_eq!( + connection.args(), + vec![ + "/IBConnectionString", + "File=/tmp/ib", + "/N", + "Admin", + "/P", + "pw", + "/UC", + "uc-secret", + ] + ); + } + + #[test] + fn omits_unlock_code_when_value_is_empty() { + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.unlock_code = Some(String::new()); + + assert!(!connection.args().iter().any(|arg| arg == "/UC")); + } + + #[test] + fn omits_unlock_code_when_not_configured() { + let connection = V8Connection::from_connection_string("File=/tmp/ib"); + + assert!(!connection.args().iter().any(|arg| arg == "/UC")); + } } diff --git a/src/platform/designer.rs b/src/platform/designer.rs index c98faa1..67be1f9 100644 --- a/src/platform/designer.rs +++ b/src/platform/designer.rs @@ -88,13 +88,22 @@ impl<'a> DesignerDsl<'a> { self.run(&args) } - /// `/UpdateDBCfg` + /// `/UpdateDBCfg [-Dynamic+] [-Extension ]` + /// + /// `dynamic = true` adds `-Dynamic+`, instructing the platform to apply the change set + /// without grabbing an exclusive infobase lock. The platform itself refuses dynamic mode + /// when restructuring is required; the runner surfaces that error verbatim instead of + /// retrying statically. pub fn update_db_cfg( &self, extension: Option<&str>, + dynamic: bool, ) -> Result { let mut args = self.base_args(); args.push("/UpdateDBCfg".to_owned()); + if dynamic { + args.push("-Dynamic+".to_owned()); + } if let Some(extension) = extension { args.push("-Extension".to_owned()); args.push(extension.to_owned()); @@ -439,6 +448,84 @@ mod tests { .contains("failed to read designer /Out log")); } + #[cfg(unix)] + #[test] + fn update_db_cfg_emits_dynamic_flag_when_requested() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let dsl = DesignerDsl::new( + script, + V8Connection::from_connection_string("File=/tmp/ib"), + &runner as &dyn ProcessRunner, + None, + ); + + dsl.update_db_cfg(None, true).expect("dynamic update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + assert!(args.contains("/UpdateDBCfg")); + assert!(args.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn update_db_cfg_omits_dynamic_flag_by_default() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let dsl = DesignerDsl::new( + script, + V8Connection::from_connection_string("File=/tmp/ib"), + &runner as &dyn ProcessRunner, + None, + ); + + dsl.update_db_cfg(Some("Ext"), false) + .expect("static update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + assert!(args.contains("/UpdateDBCfg")); + assert!(!args.contains("-Dynamic")); + assert!(args.contains("-Extension")); + } + + #[cfg(unix)] + #[test] + fn base_args_propagate_unlock_code_to_every_designer_command() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.unlock_code = Some("seal".to_owned()); + let dsl = DesignerDsl::new(script, connection, &runner as &dyn ProcessRunner, None); + + dsl.update_db_cfg(None, false).expect("update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + let lines: Vec<&str> = args.lines().collect(); + let uc_index = lines + .iter() + .position(|line| *line == "/UC") + .expect("/UC token"); + assert_eq!(lines.get(uc_index + 1).copied(), Some("seal")); + } + #[cfg(unix)] #[test] fn dump_config_to_files_requests_config_dump_info_update() { diff --git a/src/platform/process.rs b/src/platform/process.rs index f86f9f3..e85fdf5 100644 --- a/src/platform/process.rs +++ b/src/platform/process.rs @@ -615,6 +615,9 @@ fn is_sensitive_flag(arg: &str) -> bool { "--db-pwd", "--target-database-password", "--target-db-pwd", + // `/UC` carries the infobase unlock code (TASK-124) — treat it as a secret. + "/UC", + "-UC", ]; FLAGS.iter().any(|flag| arg.eq_ignore_ascii_case(flag)) @@ -629,6 +632,9 @@ fn split_sensitive_assignment(arg: &str) -> Option<(&str, &str)> { "--db-pwd", "--target-database-password", "--target-db-pwd", + // `/UC` carries the infobase unlock code (TASK-124) — treat it as a secret. + "/UC", + "-UC", ]; let (key, value) = arg.split_once('=')?; @@ -707,6 +713,26 @@ mod tests { ); } + #[test] + fn render_command_masks_unlock_code_flag() { + let rendered = render_command(&ProcessRequest { + program: Path::new("/tmp/1cv8").to_path_buf(), + args: vec![ + "DESIGNER".to_owned(), + "/UC".to_owned(), + "seal-42".to_owned(), + "/UpdateDBCfg".to_owned(), + ], + workdir: None, + stdout_log_path: None, + stderr_log_path: None, + startup_probe: None, + }); + + assert!(rendered.contains("/UC ***")); + assert!(!rendered.contains("seal-42")); + } + #[test] fn render_command_masks_ibcmd_password_flags() { let rendered = render_command(&ProcessRequest { diff --git a/src/use_cases/build_project.rs b/src/use_cases/build_project.rs index 5c2b359..6086b90 100644 --- a/src/use_cases/build_project.rs +++ b/src/use_cases/build_project.rs @@ -122,6 +122,14 @@ fn run_build_ibcmd( Ok(result) } +/// Resolves the effective `/UpdateDBCfg -Dynamic+` flag for a build invocation. +/// +/// `args.dynamic_update` (CLI/MCP one-shot override) takes priority over +/// `config.build.dynamic_update` (project-wide default). Default is `false`. +pub(crate) fn resolve_dynamic_update(config: &AppConfig, args: &BuildArgs) -> bool { + args.dynamic_update.unwrap_or(config.build.dynamic_update) +} + fn validate_designer_supported_matrix(config: &AppConfig) -> Option { if config.format == SourceFormat::Designer && matches!( @@ -416,6 +424,11 @@ fn recreate_directory(path: &Path) -> std::io::Result<()> { std::fs::create_dir_all(path) } +// Designer build step is intentionally wide: it threads the execution context, config, the +// source set and its commit/load directories, the partial-load file list and the dynamic +// flag. Refactoring into a builder is out of scope for TASK-124; the IBCMD sibling already +// breaches the same limit (9/7) without an allow. +#[allow(clippy::too_many_arguments)] fn execute_source_set_step( context: &ExecutionContext, config: &AppConfig, @@ -427,6 +440,7 @@ fn execute_source_set_step( step_index: usize, partial_paths: Option<&[PathBuf]>, commit: &StepCommit, + dynamic_update_db_cfg: bool, ) -> Result, AppError> { if let Some(error) = interruption_before_safe_point( context, @@ -517,7 +531,7 @@ fn execute_source_set_step( "update", InterruptionSafetyClass::CriticalNonAbortable, )? - .update_db_cfg(extension_name(source_set)) + .update_db_cfg(extension_name(source_set), dynamic_update_db_cfg) .map_err(AppError::from)?; ensure_platform_success("update_db_cfg", source_set, &update_result)?; @@ -851,6 +865,7 @@ mod tests { ], build: BuildConfig { partial_load_threshold: threshold, + dynamic_update: false, }, tools: ToolsConfig { platform: PlatformToolConfig { @@ -891,6 +906,7 @@ mod tests { ], build: BuildConfig { partial_load_threshold: 20, + dynamic_update: false, }, tools: ToolsConfig { platform: PlatformToolConfig { @@ -914,6 +930,15 @@ mod tests { BuildArgs { full_rebuild, source_set: None, + dynamic_update: None, + } + } + + fn build_args_dynamic(full_rebuild: bool, dynamic: bool) -> BuildArgs { + BuildArgs { + full_rebuild, + source_set: None, + dynamic_update: Some(dynamic), } } @@ -2582,6 +2607,206 @@ mod tests { assert!(matches!(rerun.steps[0].mode, BuildMode::Skipped)); } + #[cfg(unix)] + #[test] + fn dynamic_cli_flag_emits_dynamic_marker_in_update_db_cfg() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // CLI `--dynamic` should reach DESIGNER as `/UpdateDBCfg -Dynamic+`. + let result = run_build(&config, &build_args_dynamic(false, true)).expect("dynamic build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(result.ok); + assert!(calls_text.contains("/UpdateDBCfg")); + assert!(calls_text.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn build_dynamic_update_config_default_emits_dynamic_marker() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let mut config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + config.build.dynamic_update = true; + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // `build.dynamicUpdate: true` in v8project.yaml is enough — no CLI flag needed. + let result = run_build(&config, &build_args(false)).expect("dynamic build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(result.ok); + assert!(calls_text.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn build_without_dynamic_flag_emits_static_update_db_cfg() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // Regression guard: default behavior MUST NOT add `-Dynamic+`. + let _ = run_build(&config, &build_args(false)).expect("static build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(calls_text.contains("/UpdateDBCfg")); + assert!(!calls_text.contains("-Dynamic")); + } + + #[cfg(unix)] + #[test] + fn edt_build_designer_dynamic_update_modes_reach_update_db_cfg() { + for (case, config_dynamic_update, arg_dynamic_update, expected_dynamic) in [ + ("cli_dynamic", false, Some(true), true), + ("config_default_dynamic", true, None, true), + ("cli_static_overrides_config", true, Some(false), false), + ("static_default", false, None, false), + ] { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let platform_script = dir.path().join("platform").join("bin").join("1cv8"); + let edt_script = dir.path().join("edt").join("1cedtcli"); + let designer_calls = dir.path().join(format!("{case}-designer-calls.log")); + let edt_calls = dir.path().join(format!("{case}-edt-calls.log")); + create_source_tree(&base); + write_designer_script(&platform_script, &designer_calls, None); + write_edt_script(&edt_script, &edt_calls, None); + let mut config = + build_edt_config(&base, &work, &dir.path().join("platform"), &edt_script); + config.build.dynamic_update = config_dynamic_update; + prime_edt_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + format!("procedure Test()\n // changed in {case}\nendprocedure"), + ) + .expect("modify edt main"); + + let result = run_build( + &config, + &BuildArgs { + full_rebuild: false, + source_set: None, + dynamic_update: arg_dynamic_update, + }, + ) + .expect(case); + let calls_text = fs::read_to_string(&designer_calls).expect("designer calls"); + + assert!(result.ok, "{case}"); + assert!(calls_text.contains("/UpdateDBCfg"), "{case}"); + assert_eq!( + calls_text.contains("-Dynamic+"), + expected_dynamic, + "{case}: {calls_text}" + ); + } + } + + #[cfg(unix)] + #[test] + fn infobase_unlock_code_propagates_to_designer_args() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let mut config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + config.infobase.unlock_code = Some("seal-42".to_owned()); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + let _ = run_build(&config, &build_args(false)).expect("build with /UC"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + // `/UC` reaches DESIGNER as a separate token + value pair. + assert!(calls_text.contains("/UC")); + assert!(calls_text.contains("seal-42")); + } + #[cfg(unix)] #[test] fn changed_extension_only_loads_extension_and_preserves_other_storage() { @@ -2656,6 +2881,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("ext".to_owned()), + dynamic_update: None, }, ) .expect("build"); @@ -2707,6 +2933,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("ext".to_owned()), + dynamic_update: None, }, ) .expect("build"); @@ -2745,6 +2972,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("missing".to_owned()), + dynamic_update: None, }, ) .expect_err("unknown source-set must fail"); diff --git a/src/use_cases/build_project/coordinator.rs b/src/use_cases/build_project/coordinator.rs index 4f06468..5d72410 100644 --- a/src/use_cases/build_project/coordinator.rs +++ b/src/use_cases/build_project/coordinator.rs @@ -174,6 +174,7 @@ pub(super) fn run_build_designer( index, partial_paths.as_deref(), &commit, + resolve_dynamic_update(config, args), ) { Ok(warnings) => push_build_step( &mut steps, @@ -888,6 +889,7 @@ pub(super) fn run_build_edt( index, partial_paths.as_deref(), &commit, + resolve_dynamic_update(config, args), ) } BuilderBackend::Ibcmd => { diff --git a/src/use_cases/load_artifact.rs b/src/use_cases/load_artifact.rs index 54acc42..82dd4c2 100644 --- a/src/use_cases/load_artifact.rs +++ b/src/use_cases/load_artifact.rs @@ -328,8 +328,10 @@ fn run_load( "load: update_db_cfg", "[Конфигуратор] updating database configuration", ); + // `load` mirrors the historical static update — DBA approves the artifact, the runner + // applies the locked change. Dynamic mode is scoped to `build` per TASK-124. let update_result = update_dsl - .update_db_cfg(resolved.extension.as_deref()) + .update_db_cfg(resolved.extension.as_deref(), false) .map_err(AppError::from); let update_result = match update_result { diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index 2a8d812..a8a09d6 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -16,6 +16,11 @@ pub struct BuildRequest { pub full_rebuild: bool, /// Optional source-set selector. When absent, all configured source-sets are built. pub source_set: Option, + /// Per-invocation override for `/UpdateDBCfg -Dynamic+`. + /// + /// `None` means "no override — use `build.dynamic_update` from project config"; + /// `Some(true)` / `Some(false)` overrides the project default for this call. + pub dynamic_update: Option, } /// Transport-neutral request for the `tools download` use case. diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 2a7bf44..9f72195 100644 --- a/src/use_cases/run_tests/coordinator.rs +++ b/src/use_cases/run_tests/coordinator.rs @@ -76,6 +76,9 @@ pub(super) fn run_tests( &BuildArgs { full_rebuild: false, source_set: None, + // Test prerequisite builds must stay static even when the project default enables + // dynamic updates; use `build --dynamic && test` for one-shot dynamic preparation. + dynamic_update: Some(false), }, ) { Ok(result) => result, diff --git a/src/use_cases/tool_extension.rs b/src/use_cases/tool_extension.rs index 2b1229c..34003a5 100644 --- a/src/use_cases/tool_extension.rs +++ b/src/use_cases/tool_extension.rs @@ -318,8 +318,10 @@ fn prepare_designer_source_extension( "update", &extension_stage_detail("Конфигуратор", "Применение", extension), ); + // Tool-extension flow keeps the historical static update; dynamic mode is opt-in + // through `build --dynamic` / `build.dynamicUpdate` and not propagated here. let update = dsl - .update_db_cfg(Some(&extension.name)) + .update_db_cfg(Some(&extension.name), false) .map_err(AppError::from)?; ensure_tool_extension_success("update_db_cfg", extension, &update) } @@ -410,8 +412,9 @@ fn prepare_artifact_extension( extension, "update-artifact", )?; + // Tool-extension artifact apply path uses static update; see tool_extension::execute(). let update = dsl - .update_db_cfg(Some(&extension.name)) + .update_db_cfg(Some(&extension.name), false) .map_err(AppError::from)?; ensure_tool_extension_success("update_db_cfg", extension, &update) }