From b04926fdad8fa1c4c3e60283091dae54285b231e Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Tue, 12 May 2026 21:12:03 +0000 Subject: [PATCH] feat(build): add --dynamic flag and infobase.unlock_code config - CLI flag `--dynamic` for `build` enables /UpdateDBCfg -Dynamic+ - New config field `build.dynamicUpdate` (default false), CLI overrides config - New config field `infobase.unlock_code` propagates /UC to DESIGNER - Mask unlock_code in command-render logs like password (/UC ***) - Propagate dynamic flag through BuildRequest, McpBuildProjectRequest and the execute_source_set_step pipeline; `load` and tool-extension flows keep the historical static update - Bump version 0.4.2 -> 0.5.0 - Regenerate JSON schemas for v8project.yaml / v8project.local.yaml --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/CAPABILITIES.md | 8 +- docs/CONFIGURATION.md | 24 +++ docs/schemas/v8project.local.schema.json | 9 +- docs/schemas/v8project.schema.json | 13 +- src/cli/args.rs | 8 + src/cli/execute.rs | 6 + src/config/loader.rs | 31 ++++ src/config/model.rs | 22 +++ src/config/schema.rs | 14 ++ src/config/validate.rs | 2 + src/mcp/port.rs | 1 + src/mcp/request.rs | 5 + src/mcp/service.rs | 2 + src/platform/connection.rs | 52 ++++++ src/platform/designer.rs | 89 +++++++++- src/platform/process.rs | 26 +++ src/use_cases/build_project.rs | 177 +++++++++++++++++++- src/use_cases/build_project/coordinator.rs | 2 + src/use_cases/load_artifact.rs | 4 +- src/use_cases/request.rs | 5 + src/use_cases/run_tests/coordinator.rs | 3 + src/use_cases/tool_extension.rs | 7 +- v8-runner/references/config-and-backends.md | 2 + v8-runner/references/project-workflows.md | 12 ++ 26 files changed, 517 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6adb81..fb3fc0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,7 +2285,7 @@ dependencies = [ [[package]] name = "v8-runner" -version = "0.4.2" +version = "0.5.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 43a7c8d..997aea7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "v8-runner" -version = "0.4.2" +version = "0.5.0" edition = "2021" [[bin]] diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 91dbae4..f34f8bf 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -109,10 +109,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. @@ -278,7 +282,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 f708fb0..eb2c32a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -121,6 +121,7 @@ infobase: connection: "File=build/ib" user: Admin password: secret + unlock_code: seal-42 # optional `/UC <значение>` для запароленных конфигураций source-set: - name: main @@ -132,6 +133,7 @@ source-set: build: partialLoadThreshold: 20 + dynamicUpdate: false # `/UpdateDBCfg -Dynamic+` по умолчанию tools: client_mcp: @@ -318,6 +320,16 @@ tests: Credentials самой информационной базы. +#### `infobase.unlock_code` + +- Тип: строка +- Обязателен: нет + +Кодовое слово (`Конфигурация → Установить пароль`), которое транслируется в DESIGNER как +`/UC <значение>`. Без него платформа отказывается выполнять административные операции на +запароленных конфигурациях. Значение маскируется в логах команд (`/UC ***`), поэтому его +безопасно держать в `v8project.local.yaml` рядом с `infobase.password`. + #### `infobase.dbms` - Тип: объект @@ -375,6 +387,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/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 85a20c9..a6ea687 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -260,6 +260,13 @@ "null" ] }, + "unlock_code": { + "description": "Optional local infobase unlock code propagated as `/UC `. Masked in command logs.", + "type": [ + "string", + "null" + ] + }, "user": { "description": "Optional local infobase user name.", "type": [ @@ -570,7 +577,7 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.local.schema.json", + "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.5.0/docs/schemas/v8project.local.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index 18811f7..3e20ab6 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", @@ -228,6 +232,13 @@ "null" ] }, + "unlock_code": { + "description": "Optional unlock code propagated as `/UC ` to DESIGNER. Masked in command logs.", + "type": [ + "string", + "null" + ] + }, "user": { "description": "Optional infobase user name passed to platform utilities.", "type": [ @@ -623,7 +634,7 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.schema.json", + "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.5.0/docs/schemas/v8project.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { diff --git a/src/cli/args.rs b/src/cli/args.rs index 8c7dce3..4a328a5 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -116,6 +116,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 9ca1300..bedb221 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -702,6 +702,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 }, } } @@ -2471,6 +2473,7 @@ mod tests { map_build_request(&BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }) .full_rebuild ); @@ -2759,6 +2762,7 @@ mod tests { command_name(&Command::Build(BuildArgs { full_rebuild: false, source_set: None, + dynamic: false, })), CommandName::Build ); @@ -2829,6 +2833,7 @@ mod tests { &Command::Build(BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }), &presenter, false, @@ -2956,6 +2961,7 @@ mod tests { &Command::Build(BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }), &presenter, true, diff --git a/src/config/loader.rs b/src/config/loader.rs index e5335d8..2fc44f6 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -683,6 +683,37 @@ 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!( + "basePath: {}\nworkPath: {}\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: src\n", + base.display(), + 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 e5b7c1b..bd9ef09 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 b00d18c..5dd0724 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -452,6 +452,9 @@ struct InfobaseSchema { /// Optional infobase password passed to platform utilities. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, + /// Optional unlock code propagated as `/UC ` to DESIGNER. 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, @@ -474,6 +477,9 @@ struct PartialInfobaseSchema { /// Optional local infobase password. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, + /// Optional local infobase unlock code propagated as `/UC `. 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, @@ -533,6 +539,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 bdaa7f5..e3d414d 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -1360,6 +1360,7 @@ mod tests { }], build: BuildConfig { partial_load_threshold: 0, + dynamic_update: false, }, tools: ToolsConfig::default(), mcp: Default::default(), @@ -2175,6 +2176,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 465fa62..9edb627 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 @@ -826,6 +827,7 @@ mod tests { &McpBuildProjectRequest { full_rebuild: Some(true), source_set: Some("main".to_owned()), + dynamic_update: None, }, ) .expect("success"); 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..c0092bb 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,153 @@ 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 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 +2828,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("ext".to_owned()), + dynamic_update: None, }, ) .expect("build"); @@ -2707,6 +2880,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("ext".to_owned()), + dynamic_update: None, }, ) .expect("build"); @@ -2745,6 +2919,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 c0dcac6..7cde45d 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -15,6 +15,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 `load` use case. diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 6f96ce1..dfb0fab 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` always rebuilds with the project default (`build.dynamicUpdate`); the + // ergonomic CLI for one-shot dynamic remains `build --dynamic && test`. + dynamic_update: None, }, ) { 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) } diff --git a/v8-runner/references/config-and-backends.md b/v8-runner/references/config-and-backends.md index 545b5c8..971f926 100644 --- a/v8-runner/references/config-and-backends.md +++ b/v8-runner/references/config-and-backends.md @@ -11,6 +11,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, propagated to DESIGNER as `/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/v8-runner/references/project-workflows.md b/v8-runner/references/project-workflows.md index 9786ef1..5867758 100644 --- a/v8-runner/references/project-workflows.md +++ b/v8-runner/references/project-workflows.md @@ -40,6 +40,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`.