From 8c3e9a137ecf852aa152dba0590414e7f8f6209d Mon Sep 17 00:00:00 2001 From: vib-tools Date: Tue, 18 Aug 2026 11:01:30 -0700 Subject: [PATCH 1/2] feat(workflow): complete lifecycle management and true multiworkflow v1.0.6.42 --- CHANGELOG.md | 11 + CITATION.cff | 2 +- COMPATIBILITY.md | 11 + DELTA_FILE_LIST.txt | 50 +- PATCH_MANIFEST.md | 69 +- PROJECT_STRUCTURE.md | 21 + README.md | 4 + ROADMAP.md | 16 + SHA256SUMS.txt | 102 ++- UPDATE_LOG.md | 11 + VERSIONING.md | 12 + config/AppConfig/app.py | 2 +- ...orkflow_lifecycle_multiworkflow_scope.json | 123 +++ docs/docs.manifest.ygit | 6 +- docs/index.md | 10 + ...phase2-workflow-lifecycle-multiworkflow.md | 24 + ...PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md | 37 + pyproject.toml | 2 +- scripts/verify_repository.py | 177 ++++- src/vibrapilot/backend.py | 7 +- src/vibrapilot/qt_app.py | 747 +++++++++++++++--- src/vibrapilot/task_runtime_store.py | 67 +- src/vibrapilot/workflow/__init__.py | 10 + src/vibrapilot/workflow/plugin_loader.py | 323 ++++++++ src/vibrapilot/workflow/state.py | 28 +- src/vibrapilot/workspace_state.py | 131 ++- tests/test_app_config.py | 2 +- .../test_pr03_workflow_contracts_registry.py | 5 + .../test_v10614_managed_persistent_browser.py | 4 +- tests/test_v10615_workspace_persistence.py | 24 +- ..._workspace_persistence_verification_fix.py | 3 + tests/test_v10617_browser_capabilities.py | 10 +- ...10623_pr06_workflow_state_atomic_switch.py | 22 +- tests/test_v10624_pr07_workflow_showcase.py | 28 +- ...est_v10625_pr08_dynamic_workflow_inputs.py | 13 +- ...ata_persistence_reporting_compatibility.py | 26 +- ...est_v10627_pr10_workflow_error_recovery.py | 11 +- ...10628_pr11_windows_multitask_regression.py | 6 + .../test_v10630_workflow_plugin_regression.py | 7 +- tests/test_v10630_workflow_task_ui.py | 9 +- tests/test_v10634_ui_compact_polish.py | 21 +- .../test_v10637_portable_release_packaging.py | 7 +- .../test_v10638_portable_runtime_root_fix.py | 5 + ...v10642_chrome_final_acceptance_contract.py | 24 + tests/test_v10642_multiworkflow_identity.py | 138 ++++ .../test_v10642_multiworkflow_persistence.py | 179 +++++ tests/test_v10642_workflow_lifecycle.py | 244 ++++++ vibproject.ygit | 4 +- 48 files changed, 2475 insertions(+), 320 deletions(-) create mode 100644 config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json create mode 100644 docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md create mode 100644 docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md create mode 100644 tests/test_v10642_chrome_final_acceptance_contract.py create mode 100644 tests/test_v10642_multiworkflow_identity.py create mode 100644 tests/test_v10642_multiworkflow_persistence.py create mode 100644 tests/test_v10642_workflow_lifecycle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6010ab4..7020196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## v1.0.6.42 — Phase 2 Workflow Lifecycle Management + True Multiworkflow — 2026-08-18 + +- Added strict-newer workflow package Update/Replace with atomic staging, rollback and lifecycle transaction recovery. +- Added workflow Remove and default Deactivate while preserving saved workflow configuration and historical result data. +- Made default workflow activation/switch and normal workflow lifecycle operations restart-free. +- Converted the persisted active workflow into the default for new Tasks; every Task now owns an immutable workflow ID and different workflows may execute concurrently. +- Migrated workspace and TaskRuntimeStore persistence to schema v2 workflow provenance; unresolved legacy recoverable identity fails closed and completed legacy history remains non-fabricated. +- Added workflow-aware Reports filtering/provenance and Dashboard metric aggregation per workflow schema. +- Re-verified the existing Chrome prerequisite, official Google MSI, Authenticode/Google LLC, UAC and Chrome-only guard contracts without changing Chrome implementation. +- Preserved Plugin API 1, licensing, browser-profile architecture, Windows power guard, settings defaults, dependencies, CI and portable packaging. + ## v1.0.6.41 — Phase 1 Active-Page Origin Closure — 2026-08-18 - Forensically re-audited the owner-frozen v1.0.6.40 Phase 1 closure against its deterministic active-page ownership contract. diff --git a/CITATION.cff b/CITATION.cff index 7aff007..d1b798d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 title: "VibraPilot — Vib Tools Browser Automation Desktop" message: "If you reference this software, cite the Vib Tools project." type: software -version: 1.0.6.41 +version: 1.0.6.42 authors: - name: "Vib Tools" url: "https://vib.tools/" diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index c215031..ef06772 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,3 +1,14 @@ +# v1.0.6.42 Phase 2 Workflow Lifecycle / Multiworkflow Compatibility + +- Baseline compatibility: v1.0.6.41; external Plugin API 1 is unchanged. +- Existing workflow packages remain loadable; same-ID package installation now routes through a strict-newer update path instead of blind replacement. +- Default workflow state is preserved but now controls new Task creation only; existing Tasks keep immutable workflow identity. +- Workspace and runtime storage migrate from schema v1 to v2 while preserving completed legacy history without inventing workflow identity. +- Browser profile isolation, Chrome-only runtime, Chrome secure install, licensing, Windows power management, settings defaults, dependencies, CI and portable packaging are unchanged. +- No new top-level UI page is added. + +--- + # v1.0.6.41 Phase 1 Active-Page Origin Closure Compatibility - Baseline compatibility: v1.0.6.40; external Plugin API 1 workflows remain unchanged. diff --git a/DELTA_FILE_LIST.txt b/DELTA_FILE_LIST.txt index 7eed061..34b9db4 100644 --- a/DELTA_FILE_LIST.txt +++ b/DELTA_FILE_LIST.txt @@ -10,16 +10,41 @@ SHA256SUMS.txt UPDATE_LOG.md VERSIONING.md config/AppConfig/app.py -config/verification/v1.0.6.41_phase1_active_page_origin_closure_scope.json +config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json docs/docs.manifest.ygit docs/index.md -docs/updates/v1.0.6.41-phase1-active-page-origin-closure.md -docs/verification/V1.0.6.41_PHASE1_ACTIVE_PAGE_ORIGIN_CLOSURE.md +docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md +docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md pyproject.toml scripts/verify_repository.py src/vibrapilot/backend.py +src/vibrapilot/qt_app.py +src/vibrapilot/task_runtime_store.py +src/vibrapilot/workflow/__init__.py +src/vibrapilot/workflow/plugin_loader.py +src/vibrapilot/workflow/state.py +src/vibrapilot/workspace_state.py tests/test_app_config.py -tests/test_v10641_phase1_active_page_origin_closure.py +tests/test_pr03_workflow_contracts_registry.py +tests/test_v10614_managed_persistent_browser.py +tests/test_v10615_workspace_persistence.py +tests/test_v10616_workspace_persistence_verification_fix.py +tests/test_v10617_browser_capabilities.py +tests/test_v10623_pr06_workflow_state_atomic_switch.py +tests/test_v10624_pr07_workflow_showcase.py +tests/test_v10625_pr08_dynamic_workflow_inputs.py +tests/test_v10626_pr09_data_persistence_reporting_compatibility.py +tests/test_v10627_pr10_workflow_error_recovery.py +tests/test_v10628_pr11_windows_multitask_regression.py +tests/test_v10630_workflow_plugin_regression.py +tests/test_v10630_workflow_task_ui.py +tests/test_v10634_ui_compact_polish.py +tests/test_v10637_portable_release_packaging.py +tests/test_v10638_portable_runtime_root_fix.py +tests/test_v10642_chrome_final_acceptance_contract.py +tests/test_v10642_multiworkflow_identity.py +tests/test_v10642_multiworkflow_persistence.py +tests/test_v10642_workflow_lifecycle.py vibproject.ygit project/CURRENT_PROJECT_STATE.md project/README.md @@ -29,8 +54,15 @@ project/updates/v1.0.6.39-v1.0.6.40-production-readiness/04_ERROR_HANDLING_AUDIT project/updates/v1.0.6.39-v1.0.6.40-production-readiness/05_ACTUAL_IMPLEMENTATION_STATUS.md project/updates/v1.0.6.39-v1.0.6.40-production-readiness/07_ACCEPTANCE_TEST_MATRIX.md project/updates/v1.0.6.39-v1.0.6.40-production-readiness/08_REQUIREMENTS_TRACEABILITY_MATRIX.md -project/updates/v1.0.6.41-phase1-active-page-origin-closure/00_SCOPE_LOCK.md -project/updates/v1.0.6.41-phase1-active-page-origin-closure/01_FORENSIC_AUDIT_REPORT.md -project/updates/v1.0.6.41-phase1-active-page-origin-closure/02_ROOT_CAUSE_AND_FIX_REPORT.md -project/updates/v1.0.6.41-phase1-active-page-origin-closure/03_VERIFICATION_MATRIX.md -project/updates/v1.0.6.41-phase1-active-page-origin-closure/04_ACTUAL_IMPLEMENTATION_STATUS.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/00_SCOPE_LOCK.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/01_BASELINE_FORENSIC_REPORT.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/02_IMPLEMENTATION_PLAN.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/03_WORKFLOW_LIFECYCLE_TRANSACTION_PLAN.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/04_MULTIWORKFLOW_ARCHITECTURE.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/05_STATE_SCHEMA_MIGRATION_PLAN.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/06_ERROR_HANDLING_MATRIX.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/07_CHROME_FINAL_ACCEPTANCE_MATRIX.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/08_PHASE2_PROGRESS_LEDGER.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/09_ACTUAL_IMPLEMENTATION_STATUS.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/10_FINAL_VERIFICATION_REPORT.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/11_WINDOWS_ACCEPTANCE_RECORD.md diff --git a/PATCH_MANIFEST.md b/PATCH_MANIFEST.md index 3b8e434..e797686 100644 --- a/PATCH_MANIFEST.md +++ b/PATCH_MANIFEST.md @@ -1,51 +1,42 @@ -# VibraPilot v1.0.6.41 — Phase 1 Active-Page Origin Closure Replace-Ready Patch +# VibraPilot v1.0.6.42 — Phase 2 Replace-Ready Patch ## Baseline identity -- Official input: `VibraPilot_Official_v1.0.6.40_Baseline(1).zip` -- Input SHA-256: `b66cd21c1233761dbc6584f173c28017632f795bf16e98afc7eb1ffb2e2e6ad0` -- Version: `1.0.6.40` -- Git branch: `main` -- Git commit: `7e6f4cc7abf49e08d4a94124ebffa97bb7794137` -- Git tree: `8b8b6f8e502011d730ac4508300c25273e3dbab5` -- Uploaded workspace note: five tracked files differ only by line endings; semantic `git diff --ignore-space-at-eol` is empty. +- Official input: `VibraPilot_Official_v1.0.6.41_Baseline(1).zip` +- Input SHA-256: `9296626e20076a5ded1a2c6b854ce25489b09be9d8fb204061eba14612642982` +- Baseline version: `1.0.6.41` +- Baseline Git commit: `615fe1148431b90334e9ff3f9ae02b37a36bd1d8` +- Baseline Git tree: `a6cb42814d7ed993ff5961823cf681e0cb0c0252` +- Target version: `1.0.6.42` -## Confirmed finding +## Scope -`AutomationWorker._origin_from_url()` returned `None` for an omitted port but `443`/`80` for the browser-equivalent explicit default port. In a restored multi-tab context, `_select_preferred_page()` could therefore fail target-origin matching and choose an unrelated last usable tab. +- strict-newer Workflow Update/Replace with staged validation, rollback and crash recovery; +- Workflow Remove/Unload and default Deactivate; +- restart-free normal activation/switch/lifecycle; +- immutable per-Task workflow identity and simultaneous different-workflow Tasks; +- workspace/runtime schema-v2 workflow provenance; +- workflow-aware Reports and per-workflow Dashboard metrics; +- existing Chrome prerequisite/secure-install source re-verified byte-frozen. -## Production correction scope +## Frozen boundaries -- `src/vibrapilot/backend.py` -- Method: `AutomationWorker._origin_from_url` -- Default HTTPS `:443` → canonical omitted/default representation -- Default HTTP `:80` → canonical omitted/default representation -- Non-default ports remain significant -- Malformed-port fail-safe remains unchanged +Plugin API 1, Chrome prerequisite implementation, browser profile architecture, power management, licensing, runtime settings defaults, dependencies, CI and portable packaging are unchanged. -## Frozen scope +## Automated verification -No changes are authorized to Qt UI/UX, Windows power implementation, browser settings, workflows/plugin lifecycle, licensing, persistence schemas, dependencies, CI, portable-release architecture or Phase 2 features. +- repository verifier: **PASS** +- full pytest: **541 passed, 6 skipped, 105 subtests passed** +- full unittest: **201 OK, 6 skipped** +- compileall: **PASS** +- `git diff --check`: **PASS** +- deleted files: **0** -## Verification state +## Replace-ready inventory -- Tests-first reproduction: **2 FAILED / 2 PASSED** before the fix. -- Targeted correction regression: **17 PASSED**. -- Complete Phase-1 work-package gate: **28 PASSED**. -- Metadata/scope integration: **8 PASSED** + repository verifier **PASS**. -- Final full pytest: **510 passed, 6 skipped, 105 subtests passed**. -- Final unittest: **201 OK, 6 skipped**. -- compileall: **PASS**. -- Public changed/new files: **23**. -- Private `project/` changed/new files: **13**. -- Total replace-ready delta entries: **36**. -- Deleted files: **0**. -- Delta-apply sealing: recorded after package construction. +- Public changed/new files: **48** +- Private/local `project/` files: **20** +- Total Delta entries: **68** +- `project/**` is private/local only and must never be staged or pushed. -## Phase 2 - -**NOT STARTED.** Planned version moves from v1.0.6.41 to v1.0.6.42 because v1.0.6.41 is consumed by this forensic seal. - -## Private development documentation - -`project/**` remains local/private, is ignored by Git, and must never be staged or pushed. +Windows live acceptance and GitHub v1.0.6.42 CI remain pending external evidence. diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 9091322..890c051 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -1,3 +1,24 @@ +# Project Structure — v1.0.6.42 Phase 2 + +```text +src/vibrapilot/workflow/plugin_loader.py + Workflow package lifecycle: strict-newer update, staging/swap rollback, transaction recovery, remove. +src/vibrapilot/workflow/state.py + Restart-free default workflow commit/deactivation. +src/vibrapilot/qt_app.py + Per-Task workflow selection/binding, lifecycle actions, Reports/Dashboard multiworkflow UI integration. +src/vibrapilot/workspace_state.py + Workspace schema v2 per-Task workflow provenance. +src/vibrapilot/task_runtime_store.py + Runtime DB schema v2 run/result workflow provenance. +src/vibrapilot/backend.py + Worker report rows include immutable Task workflow identity. +``` + +Chrome secure-install, licensing, power, browser-profile, dependency, CI and portable-release files remain outside the Phase-2 production-change surface. + +--- + # Project Structure — v1.0.6.41 Phase 1 Active-Page Origin Closure ```text diff --git a/README.md b/README.md index 1ff7efd..51ae20a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +# VibraPilot v1.0.6.42 — Workflow Lifecycle + True Multiworkflow + +v1.0.6.42 completes the approved Phase 2 production update: trusted workflow packages can be updated, removed or deactivated safely without normal app restart; the persisted default workflow applies only to newly created Tasks; each Task owns an immutable workflow identity; and different workflows can execute concurrently under the existing Task/profile isolation rules. Workspace/runtime persistence, Reports and Dashboard now retain workflow provenance. The existing Google Chrome prerequisite and secure installer implementation remains unchanged and fail-closed. + # VibraPilot v1.0.6.41 — Phase 1 Active-Page Origin Closure v1.0.6.41 is a scope-locked forensic seal over v1.0.6.40. It corrects one proven deterministic active-page ownership defect: browser-equivalent default HTTP/HTTPS ports are now canonicalized before target-origin comparison, preventing an explicit `:80`/`:443` workflow page from losing ownership to an unrelated restored tab. All other Phase 1 runtime behavior remains frozen. Phase 2 is not implemented and is now planned for v1.0.6.42. diff --git a/ROADMAP.md b/ROADMAP.md index b8b6bd6..72fa7ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,3 +1,19 @@ +# Current Roadmap — v1.0.6.42 Phase 2 + +## Phase 2 — Workflow Lifecycle Management & True Multiworkflow + +**LOCAL CODE + AUTOMATED VERIFICATION COMPLETE / WINDOWS LIVE ACCEPTANCE PENDING.** v1.0.6.42 completes strict-newer workflow update/replace, package remove, default deactivation, restart-free normal lifecycle operations, immutable per-Task workflow identity, simultaneous different-workflow Tasks, workflow-aware persistence/recovery/reporting and per-workflow Dashboard metrics. + +## Chrome final-acceptance track + +The existing v1.0.6.32 Chrome prerequisite/secure-install implementation is preserved. Source-level contracts are re-verified; real Windows Chrome/UAC and 1/2/4 Task acceptance remains owner evidence before public release closure. + +## Next gate + +Final repository verification → Replace-Ready Delta → owner Windows acceptance → GitHub feature branch/CI. No later feature phase starts automatically. + +--- + # Current Roadmap — v1.0.6.41 Phase 1 Active-Page Origin Closure ## Phase 1 seal — v1.0.6.41 diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt index 9068567..e88cb45 100644 --- a/SHA256SUMS.txt +++ b/SHA256SUMS.txt @@ -1,35 +1,67 @@ -b63c3fbe94072b76720b1765534fd361e52a912b20d474cc557460dfc1247ec6 CHANGELOG.md -5c5231938048066ab1b1939411b12bff22357f2ee8bd67c0606bbbed162191b0 CITATION.cff -d1162551171bbab39ab101791c849d5ce702a5c17c65bc056df7c975891d5900 COMPATIBILITY.md -1274fb250111aa5fcb32a235da01c9ea753fb2732604eab4b359ee996602ceec DELTA_FILE_LIST.txt -37f7ceea63b8e345f0bcfe79c5f7b96837a63ce885c610f89564c6b68ef30b24 PATCH_MANIFEST.md -1f8ea3eb3e069ad2e332ea754baed716fd6a085915356924a969715a57ef6e05 PROJECT_STRUCTURE.md -264175c77083474bd85529eb4e7d4fb7d065acce447d4d0b4f076c24984bb411 README.md -4834da6edf44cdf7b4fc92bce2c33fbf4394c05d9fdc6992d8252991bff38526 ROADMAP.md -5376b304728ec1be7516ede5553380d2635ca6aa15ae05ed367c0bc66cae0e1d UPDATE_LOG.md -3cca9abb4ebe555abb05898cfbb07fb75ea04e784c210e9427f60d6574d31b8e VERSIONING.md -6cf5da4f71014adddf1ef78ee800d8595dfb5d7f93627c0a888f2c0aae1417c0 config/AppConfig/app.py -baf2b7247af2c3f5a58888e4e52f2c2c9a07a0cb2fff5a667d38e405629f66ce config/verification/v1.0.6.41_phase1_active_page_origin_closure_scope.json -c4031df16f3dd814b605525b424281f53c26bb01bd90edadebf29b0ba53a4dd9 docs/docs.manifest.ygit -4e77764d4c0396248c849a0373187f51e20667eea2984e14934e84b15b189da3 docs/index.md -8e1e716dde2dfcdfb74e7c69b7134fb6bbf4b50a11d056241e83b45347eff5b8 docs/updates/v1.0.6.41-phase1-active-page-origin-closure.md -a1e77d258680ac989d949289fe16f0cbfdd4912e194d8acbac99514aadd48ecd docs/verification/V1.0.6.41_PHASE1_ACTIVE_PAGE_ORIGIN_CLOSURE.md -5ea7e5fd0995f21056307ee92feaaf70cd9d282c03f2b2b8b4961f48e18c9091 pyproject.toml -497359e647f802ca4c670ab0177de1c15b3361b4cb188d03498655eb39963005 scripts/verify_repository.py -39f231b68288de9d3de29c6d60307d812fe16d12fb6b492fc6d878f9f2045fcb src/vibrapilot/backend.py -c9bdb15de84747a9ddf654de4728c44d223b04ecdd44b0ef0dfd97b7bfd73239 tests/test_app_config.py -daac6d19117b5803eb019e99dfa397c0f8ad48776537aa5196d2f8ac66fc0f44 tests/test_v10641_phase1_active_page_origin_closure.py -c3ecad385f06d4bcfd2da73df864f54f6ffa1e959da63a1a42dc9274ada86e84 vibproject.ygit -2b395ed727838155efff257ce199e2c09567251934734660b0de0f64b38995f3 project/CURRENT_PROJECT_STATE.md -1ea00525ff893d7fcd93140e7ce9bbb508d2543b66bf8f4e948de44f88877645 project/README.md -31da689befb1b7331fa59e9a46e000b14cfb1394f6a8fb5d4ab9ebfacecfe96a project/updates/v1.0.6.39-v1.0.6.40-production-readiness/02_TWO_PHASE_ROADMAP.md -1f618998d004a8e6f0836e5455d00c63f5fb249d835df6df9ba5358c343e02d9 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/03_PHASE_PROGRESS_COMPLETION_LEDGER.md -a9b26ee7a7d412dac5c97f103ffa8b8370e6385ae45702c88b03a8535726a807 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/04_ERROR_HANDLING_AUDIT_AND_PLAN.md -42876ec3b4077d330de0524f400c14a3b8406bb2651cd5f2e2bfd3f9b8525ac2 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/05_ACTUAL_IMPLEMENTATION_STATUS.md -bfee6db2a378ec499dfb7c188fd1d0827feceec912fc00364fb268e86bf7d714 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/07_ACCEPTANCE_TEST_MATRIX.md -abada4fb606469c5d68d161809d61ad1eb1070c54830b5a6ba8e63fdf473eff9 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/08_REQUIREMENTS_TRACEABILITY_MATRIX.md -706e43030a291d4403f0fedb8a0ca92273e6a531ea274e93ae35daa5874ade55 project/updates/v1.0.6.41-phase1-active-page-origin-closure/00_SCOPE_LOCK.md -c45735d6752fe2aeb454064b127540c19ba7519f2152abd9561f95f7386db2cd project/updates/v1.0.6.41-phase1-active-page-origin-closure/01_FORENSIC_AUDIT_REPORT.md -8c4d9ccdcb046129123ad775bc59cae6102b6cebd476de4945b46b3d2c4ad43e project/updates/v1.0.6.41-phase1-active-page-origin-closure/02_ROOT_CAUSE_AND_FIX_REPORT.md -10a32370259e073944868e682f7829b2d2b5221a39e2c408570b8fda67a1d286 project/updates/v1.0.6.41-phase1-active-page-origin-closure/03_VERIFICATION_MATRIX.md -6da23c5eb5be351a3c23ebc2d31ae149c5f8de71eb096815ee9513d4deafde5d project/updates/v1.0.6.41-phase1-active-page-origin-closure/04_ACTUAL_IMPLEMENTATION_STATUS.md +75c64d5dc254e53ecbea004d64e31acae060b50eccbc4244d4f79d216de1060d CHANGELOG.md +5e8b43841c41ccec04098bd9ab7dbf992d000e905bbdff46274f2b4b9c49753b CITATION.cff +3d2418dc8dc73c6bd373d03b3b300e05694302a79100be21edf8c9ccc578f87b COMPATIBILITY.md +f1390afa16726c533012221596f6e3b920c5ea80d798d970324a513855a22679 DELTA_FILE_LIST.txt +efb2921213e26637cfe2c60eda9cff995517d49f5c834246a49391807880383e PATCH_MANIFEST.md +e10336f406462d659c8d27cb01d676b6310edc95989f445a277cd4c5d5617c8c PROJECT_STRUCTURE.md +7cb3d18959992071ae121c43d5f77b268b803129d331bea625c3d821b699c2f4 README.md +38a85064f1b34a6234d7746089558d04f9b6ce929e38e07c126144b7e4704d88 ROADMAP.md +cb445e8dd23c49f38789cabe7d8444d1c320c50712373dd714c10da5513aa8eb UPDATE_LOG.md +7f7ff4366e7757fd6d78ba3a036f37deca2a7587f7f384817f2ab0bb77334883 VERSIONING.md +85e8bdf9f1e6a33599ec79632578bff1f1999401aebbade9f29402b488d37d94 config/AppConfig/app.py +d509f79f603ae60d548ddb73da1fc218147e913ab1c9cce5e1b780568b2dcbc5 config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json +fae806875ddf9b2465e6bc054ce9bbd3f2befbd18d5f8a8384db970fbb9412fc docs/docs.manifest.ygit +0b57282606ad3b9fe3d7e939c97073716391d294d569cf2fda532d24c7a3d862 docs/index.md +cc352e49c89089b7af9cd287543e61a87920aec19328c726403954ce5ed84fd2 docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md +b7e5dcd2a01c59ff6fb346ffbd2cbb079331355a9b3f2fe30cce62d793ca24be docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md +6ef90acd1a967737c2824bc6241eb70fed2a094894b1e03cc380e9e6694f6034 pyproject.toml +9089e5a5d93b8f63d31d3c1692059b6720892a5638b6f42d75b63bf8e9414d08 scripts/verify_repository.py +a3badec5f9473aea559ead1f891fa561a6f90c764f76900d87c380c3de7733af src/vibrapilot/backend.py +6725d668ce31d99f73f202e70607820f87961e28741e576b8733a330de43507c src/vibrapilot/qt_app.py +a5fd334d54d2a512f75f5c2f491727cf475673154a4583c70cada366b486e03a src/vibrapilot/task_runtime_store.py +a3242663312fef3fe44156f1b9ae3d5821bdb5d2de3d629e5814183615ce1f5a src/vibrapilot/workflow/__init__.py +5e75aa4ffdc870d5936031036259cb29eb0ef56de7706adc2364b5c3ea4eaff9 src/vibrapilot/workflow/plugin_loader.py +e89f94635020db073f00a12c7dc536f2e8a564fc36aae1b1d1d9f5e8dd71099f src/vibrapilot/workflow/state.py +049c3811ea790cf4c043bbbbd13962630c88dfc11e35eb6cb9be99eedd8c19be src/vibrapilot/workspace_state.py +ee5acf375120c6096e1badfe744c839f35008ac5295f5a3dcdb39962a63e62a8 tests/test_app_config.py +4f147e74964ff0a34d52773a7ed0b3f23b8287f619274c871521babb438233b7 tests/test_pr03_workflow_contracts_registry.py +98f0fd053b4302f7fc8acdcb0daa79d22196101f23f1984f2acc64d06ccca2d5 tests/test_v10614_managed_persistent_browser.py +bf6adb7f04334eaf18aa3341c4a9235e2619a766bb850f6fc9df348adbb5ab63 tests/test_v10615_workspace_persistence.py +d3e3d16dabddb9b106b3fb3c05a4524385d7e2457a67d2bcdfa244389d4f9162 tests/test_v10616_workspace_persistence_verification_fix.py +4c67a25fe79bd08b1fed4fec4e4d933745d024013d55f0918988e66d244d3505 tests/test_v10617_browser_capabilities.py +0cd04a4b7cb732c3e806f47a86380c554f418e742d8a9853ce6ab8ad841972d3 tests/test_v10623_pr06_workflow_state_atomic_switch.py +92a5a3e489016c40c530c220f472760b3633714326144886ebaf3b82cfec146c tests/test_v10624_pr07_workflow_showcase.py +eee85363157f2f8a7c6f52ab9c0794b28c54af35f4074cdb8ab0e45d67f08ac4 tests/test_v10625_pr08_dynamic_workflow_inputs.py +61708510305a602c25864e2825b1203b7afbeb74af3009407f323e79039d5001 tests/test_v10626_pr09_data_persistence_reporting_compatibility.py +e4bf3d6c984c25f19f8e7ea965f4c477e830d80e82f4635700fb62e303a35816 tests/test_v10627_pr10_workflow_error_recovery.py +b683274bbd81b3fe8d2eb90f289af2e7ce2823c02f9ddcf5faf66bfb7d7c3b8c tests/test_v10628_pr11_windows_multitask_regression.py +050e48547db600fc829a273f309c6782338af1d584962d43efb0924abff4f737 tests/test_v10630_workflow_plugin_regression.py +77a6d2bd38f8e3e2dfa7e18e3bea644aabc45dca23cc2ce08e8d32f68689789a tests/test_v10630_workflow_task_ui.py +0d21e3eeeda2094acc1cab03675ab97bf5c6ef45f618f5566445a427770df788 tests/test_v10634_ui_compact_polish.py +c3636e7bb21d91e1a702ffe20eb2975a3b143f0b8cf6de0541e40786cc7ebaa3 tests/test_v10637_portable_release_packaging.py +c7ed7b50177aa1f59b44a0380e6b80853044755fed0e0392aa1e5eae11d7dc5a tests/test_v10638_portable_runtime_root_fix.py +e711976349a3ea31b36d9fcc2a247b4517e277f53bd3be73ae29871dccf5f44c tests/test_v10642_chrome_final_acceptance_contract.py +7cafac1d828e5c24a172b2160e10f0e081430b9accde3bb7bcd701b13a726f23 tests/test_v10642_multiworkflow_identity.py +bba85a890c0b212bc151dce50de5ae63e4445c93db6b1e41cf3505e9eb78f7a5 tests/test_v10642_multiworkflow_persistence.py +de779902c7d025a4ab4d15c6df03961faf65bb1baa66f92126ca8fe9dcbdba64 tests/test_v10642_workflow_lifecycle.py +dd060b860f125f54b7bd31bea5c535acdecf53aaf659c4d4d36923a61f6d0d20 vibproject.ygit +b134951150468917983dbf1ce5bec993b18043807deccf02fbb4df8a6d1e5c26 project/CURRENT_PROJECT_STATE.md +87d7f2cee4f0001da74e240717c342f9778fd4389c32dbec24a234314b556932 project/README.md +9c6c71513b686a3723642061124237fa54b5716669933fbb80125c608b7742bc project/updates/v1.0.6.39-v1.0.6.40-production-readiness/02_TWO_PHASE_ROADMAP.md +ea46e3f37b32aaae15aa6a9376691512f75db7c1ae87f4311154808fcf140990 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/03_PHASE_PROGRESS_COMPLETION_LEDGER.md +8a79d2d3a61f699b16f59e61fe1276ab0008f98e97688175c8fa5ee046fb7f5e project/updates/v1.0.6.39-v1.0.6.40-production-readiness/04_ERROR_HANDLING_AUDIT_AND_PLAN.md +6b5fcb40b70f75904a013a063705dec07d0e499c9d854d41b2ac88f67653d03e project/updates/v1.0.6.39-v1.0.6.40-production-readiness/05_ACTUAL_IMPLEMENTATION_STATUS.md +ae53198d430c6c1fbe67743c1cd4e5b6b7e6eba50c70f1557b3293c755c76d52 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/07_ACCEPTANCE_TEST_MATRIX.md +d1caefabf76b0e049f36b0eab2b05f33610d65c98be219cb6f5a533134561e99 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/08_REQUIREMENTS_TRACEABILITY_MATRIX.md +b1608dd019747fce5f60452a70cbb9817043249a7b92643b9f64ad9aacb3afde project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/00_SCOPE_LOCK.md +a4050f7147cdfa0f4cc78c9071aca7c75415b0e0c59719221e983e16cc2c78fc project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/01_BASELINE_FORENSIC_REPORT.md +87ee3a4119b592dd7a65bd6b22d4d7d0d6485fffa773b96a1b7329fba0744dae project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/02_IMPLEMENTATION_PLAN.md +f7d409ecaa2b04d7f8eeda8d70dff035426901fbf2917ace739399613907e133 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/03_WORKFLOW_LIFECYCLE_TRANSACTION_PLAN.md +87b42f53d61c52434d08108fe305a8c9e7e2d650c4aa2858a15ac3df71c81abd project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/04_MULTIWORKFLOW_ARCHITECTURE.md +f17ff666820f5177c9cc14f393b14f263544165e60f0be50d7fba956e7bf1db6 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/05_STATE_SCHEMA_MIGRATION_PLAN.md +ec11459210e79e120fb2196dc574991dd1fcc43a1181015e1337a7d258227c09 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/06_ERROR_HANDLING_MATRIX.md +c7e719c435c412daddd7bcc4c2c783f2dc111f4fb5d1caebfe7971c925c3c36c project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/07_CHROME_FINAL_ACCEPTANCE_MATRIX.md +e29a08c771f019c0c12b8a7e0bd2a1bce52e7659223775effbb64a8ac043c834 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/08_PHASE2_PROGRESS_LEDGER.md +f6a3934711ed905e4d3909159d9bbee3c367ef6a46d4ee9f3d1e0f8346751eea project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/09_ACTUAL_IMPLEMENTATION_STATUS.md +208cda8bed0fc7a6bc54d307806e98854178eb5b9fed46ea15d490e480885950 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/10_FINAL_VERIFICATION_REPORT.md +9778eb9db98e1604d42037bbdb46f48922d67d1e473f0a36538bc4b8c69bcfdd project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/11_WINDOWS_ACCEPTANCE_RECORD.md diff --git a/UPDATE_LOG.md b/UPDATE_LOG.md index a06c59a..576a5d4 100644 --- a/UPDATE_LOG.md +++ b/UPDATE_LOG.md @@ -1,3 +1,14 @@ +# v1.0.6.42 — Phase 2 Update Log — 2026-08-18 + +- Workflow lifecycle transaction/update/remove implementation added and tested fail-closed. +- Default workflow activation/deactivation made restart-free without clearing existing Tasks. +- Per-Task workflow identity wired through Task creation, worker runtime, persistence, recovery, reports and dashboard. +- Workspace/TaskRuntimeStore workflow provenance schema migrations added. +- Chrome secure-install implementation re-verified under frozen-source policy. +- Final full regression and Windows owner live acceptance are separate release gates. + +--- + ## v1.0.6.41 — Phase 1 Active-Page Origin Closure Forensic verification of v1.0.6.40 reproduced one P1-WP04 defect: omitted default ports and explicit `:80`/`:443` were treated as different origins. `_origin_from_url()` now canonicalizes only those default-port equivalents; non-default ports and malformed-port fail-safe behavior are unchanged. Production source change is limited to `src/vibrapilot/backend.py`. diff --git a/VERSIONING.md b/VERSIONING.md index 5e9d32e..efb3b91 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -1,3 +1,15 @@ +# v1.0.6.42 Version Boundary + +- Baseline: v1.0.6.41 / `615fe1148431b90334e9ff3f9ae02b37a36bd1d8`. +- Target: v1.0.6.42. +- Classification: Phase 2 Workflow Lifecycle Management + True Multiworkflow + Chrome prerequisite final verification. +- External Plugin API remains 1. +- Workspace state schema advances 1 → 2 for per-Task workflow identity. +- Task runtime database schema advances 1 → 2 for run/result workflow provenance. +- No dependency, CI, portable packaging or licensing version boundary change. + +--- + # Current Candidate — v1.0.6.41 Phase 1 Active-Page Origin Closure - Official input baseline: **v1.0.6.40**, commit `7e6f4cc7abf49e08d4a94124ebffa97bb7794137`. diff --git a/config/AppConfig/app.py b/config/AppConfig/app.py index b898e36..c18b4a8 100644 --- a/config/AppConfig/app.py +++ b/config/AppConfig/app.py @@ -12,7 +12,7 @@ DESCRIPTION = "VibraPilot browser automation desktop application by Vib Tools." TAGLINE = "Authorized browser automation desktop application." -VERSION = "1.0.6.41" +VERSION = "1.0.6.42" CREATED_DATE = "2026-08-07" RELEASE_DATE = "2026-08-18" UPDATED_DATE = "2026-08-18" diff --git a/config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json b/config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json new file mode 100644 index 0000000..dc58865 --- /dev/null +++ b/config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json @@ -0,0 +1,123 @@ +{ + "plan_id": "VP-V10642-PHASE2-WORKFLOW-LIFECYCLE-MULTIWORKFLOW-001", + "classification": "Phase 2 / Workflow Lifecycle Management + True Multiworkflow + Chrome Prerequisite Final Acceptance", + "baseline_version": "1.0.6.41", + "target_version": "1.0.6.42", + "baseline_commit": "615fe1148431b90334e9ff3f9ae02b37a36bd1d8", + "baseline_tree": "a6cb42814d7ed993ff5961823cf681e0cb0c0252", + "baseline_zip": "VibraPilot_Official_v1.0.6.41_Baseline(1).zip", + "baseline_zip_sha256": "9296626e20076a5ded1a2c6b854ce25489b09be9d8fb204061eba14612642982", + "scope": "Complete workflow lifecycle management and true per-Task multiworkflow while re-verifying the existing Chrome prerequisite secure-install implementation without changing frozen Chrome, licensing, browser-profile, power, dependency, CI or packaging architecture.", + "allowed_production_source_changes": [ + "src/vibrapilot/backend.py", + "src/vibrapilot/qt_app.py", + "src/vibrapilot/task_runtime_store.py", + "src/vibrapilot/workflow/__init__.py", + "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workflow/state.py", + "src/vibrapilot/workspace_state.py" + ], + "authorized_automationworker_method_changes": [ + "report_row" + ], + "allowed_runtime_config_changes": [], + "plugin_api_version": 1, + "database_schema": { + "from": 1, + "to": 2, + "new_field": "workflow_id", + "tables": [ + "runs", + "results" + ] + }, + "workspace_schema": { + "from": 1, + "to": 2, + "task_field": "workflow_id", + "unresolved_legacy": "fail_closed_without_rewrite" + }, + "new_top_level_ui_pages": false, + "chrome_implementation_changes": 0, + "required_contracts": { + "strict_newer_workflow_update_only": true, + "atomic_update_rollback_and_crash_recovery": true, + "remove_preserves_user_state_and_history": true, + "deactivate_default_workflow": true, + "restart_free_default_activation_switch_and_lifecycle": true, + "default_workflow_applies_to_new_tasks_only": true, + "immutable_per_task_workflow_identity": true, + "simultaneous_different_workflow_tasks": true, + "workspace_and_runtime_workflow_provenance": true, + "reports_workflow_column_and_filter": true, + "dashboard_metrics_grouped_by_workflow_schema": true, + "legacy_completed_history_not_fabricated": true, + "unresolved_recoverable_identity_fails_closed": true, + "plugin_api_1_preserved": true, + "chrome_secure_install_reverified_unchanged": true, + "no_chromium_fallback": true + }, + "frozen_file_sha256": { + "src/vibrapilot/chrome_runtime.py": "f13a64271af900ddb4aaba7af348bed0607be772f5ca4e993bd9cd54173e9fcd", + "src/vibrapilot/chrome_installer.py": "bad5f96db58c04a5bb105dd3cdbfcefed654d699a971a2e0a460161c4377041b", + "src/vibrapilot/windows_authenticode.py": "6f03731087c2eaff23a4b92d25ea9a365c0e7ddef2cecd984824040f4771c6a0", + "src/vibrapilot/power_management.py": "69a8e54bafc825e45492a8e662acf0223adbbbb6617dd08991d16c8077eff6d2", + "config/settings.defaults.json": "f0ed6cd3f24d6f18fd0445eaafd75136d128d52dcaca28f5fe438b71f95943ad", + "src/vibrapilot/licensing_v2.py": "36435d05593785b7ec02310dcf2d031342524e95d5be6212591a09277699a91b", + "src/vibrapilot/workflow/manager.py": "d502314d9c17132b3d436b1bdbd73569e618a0f091c1e383cca58914de15bc49", + "src/vibrapilot/workflow/contracts.py": "5c3210746e7e79c905075a887d2fba2a1a9446e4f22bd127e763bd5a18490b2c", + "src/vibrapilot/workflow/schemas.py": "a2b5e48af9530e00206fd21c9e9adf16c7302aa2e0dce7ae0c6b74754458d1a1", + ".github/workflows/ci.yml": "a722955f9860315f77abdeb8b75cd1bfc269db24e8d46d437dd678917ba258a3", + ".github/workflows/portable-release.yml": "342bd815df0fa4e34170eaaecc5c47abc2619a9d600718b7f7ac564fa4586a62", + "requirements.txt": "92890827d0d19fe07168cf801d15c96cf48b846813d7f687a04531e40ed2b083", + "requirements-portable.txt": "4c84508a5399ce66ed3d7393e672fa5658d6a55bb6f86ac386dce9ce27f4f861", + "requirements-build.txt": "39d98aacb5781de72933397e6c431b83a4b62aa1177798600db1907c8def53eb" + }, + "private_project_docs": { + "path": "project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/", + "github_public": false + }, + "authorized_nonproduction_files": [ + "config/AppConfig/app.py", + "pyproject.toml", + "CITATION.cff", + "vibproject.ygit", + "scripts/verify_repository.py", + "config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json", + "CHANGELOG.md", + "README.md", + "UPDATE_LOG.md", + "VERSIONING.md", + "ROADMAP.md", + "COMPATIBILITY.md", + "PROJECT_STRUCTURE.md", + "PATCH_MANIFEST.md", + "DELTA_FILE_LIST.txt", + "SHA256SUMS.txt", + "docs/index.md", + "docs/docs.manifest.ygit", + "docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md", + "docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md", + "tests/test_v10614_managed_persistent_browser.py", + "tests/test_v10615_workspace_persistence.py", + "tests/test_v10617_browser_capabilities.py", + "tests/test_v10623_pr06_workflow_state_atomic_switch.py", + "tests/test_v10624_pr07_workflow_showcase.py", + "tests/test_v10625_pr08_dynamic_workflow_inputs.py", + "tests/test_v10626_pr09_data_persistence_reporting_compatibility.py", + "tests/test_v10627_pr10_workflow_error_recovery.py", + "tests/test_v10628_pr11_windows_multitask_regression.py", + "tests/test_v10630_workflow_plugin_regression.py", + "tests/test_v10630_workflow_task_ui.py", + "tests/test_v10642_chrome_final_acceptance_contract.py", + "tests/test_v10642_multiworkflow_identity.py", + "tests/test_v10642_multiworkflow_persistence.py", + "tests/test_v10642_workflow_lifecycle.py", + "tests/test_app_config.py", + "tests/test_pr03_workflow_contracts_registry.py", + "tests/test_v10616_workspace_persistence_verification_fix.py", + "tests/test_v10634_ui_compact_polish.py", + "tests/test_v10637_portable_release_packaging.py", + "tests/test_v10638_portable_runtime_root_fix.py" + ] +} diff --git a/docs/docs.manifest.ygit b/docs/docs.manifest.ygit index 6e4f092..cf84c39 100644 --- a/docs/docs.manifest.ygit +++ b/docs/docs.manifest.ygit @@ -3,12 +3,12 @@ "documentation": { "id": "vibrapilot", "title": "VibraPilot Documentation", - "description": "Documentation for VibraPilot v1.0.6.41 Phase 1 Active-Page Origin Closure.", - "version": "1.0.6.41", + "description": "Documentation for VibraPilot v1.0.6.42 Phase 2 Workflow Lifecycle Management + True Multiworkflow.", + "version": "1.0.6.42", "root": "docs", "home": "index.md" }, "product": { - "description": "v1.0.6.41 is a scope-locked Phase 1 forensic seal over v1.0.6.40: deterministic page-origin matching now canonicalizes HTTP :80 and HTTPS :443 so equivalent workflow pages are not rejected in favor of unrelated restored tabs. Phase 2 remains unimplemented." + "description": "v1.0.6.42 completes restart-free workflow package update/remove/deactivate and immutable per-Task multiworkflow identity, adds workflow provenance to workspace/runtime reporting, and re-verifies the existing Chrome prerequisite secure-install implementation without changing it." } } diff --git a/docs/index.md b/docs/index.md index a9e2d40..d82862e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,13 @@ +# VibraPilot Documentation — v1.0.6.42 Phase 2 + +- Update: `docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md` +- Verification: `docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md` +- Scope contract: `config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json` +- Classification: Workflow Lifecycle Management + True Multiworkflow + Chrome prerequisite final verification. +- Windows live acceptance remains owner evidence before release closure. + +--- + # VibraPilot Documentation — v1.0.6.41 Phase 1 Active-Page Origin Closure - Update: `docs/updates/v1.0.6.41-phase1-active-page-origin-closure.md` diff --git a/docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md b/docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md new file mode 100644 index 0000000..1477fbe --- /dev/null +++ b/docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md @@ -0,0 +1,24 @@ +# VibraPilot v1.0.6.42 — Phase 2 Workflow Lifecycle Management + True Multiworkflow + +## Baseline + +- Official baseline: v1.0.6.41 +- Git commit: `615fe1148431b90334e9ff3f9ae02b37a36bd1d8` +- Git tree: `a6cb42814d7ed993ff5961823cf681e0cb0c0252` +- Baseline ZIP SHA-256: `9296626e20076a5ded1a2c6b854ce25489b09be9d8fb204061eba14612642982` + +## Production update + +- Adds strict-newer workflow package update/replace with staging, post-swap validation, rollback and startup transaction recovery. +- Adds workflow package Remove and default-workflow Deactivate without deleting saved workflow inputs/settings or historical result data. +- Makes default workflow activation/switch restart-free. The persisted default now selects the workflow for new Tasks only. +- Gives every Task an immutable workflow identity used for Task schema, inputs/settings snapshots, runtime factory and worker execution. Different workflows may run concurrently within existing Task/profile concurrency rules. +- Adds workspace schema v2 and TaskRuntimeStore schema v2 workflow provenance. Legacy completed records keep unknown identity instead of receiving a fabricated workflow. Unresolved recoverable identities fail closed. +- Adds workflow-aware Reports column/filter and workflow-schema-isolated Dashboard activity aggregation. +- Preserves the existing top-level navigation and visual design; no new page is introduced. +- Re-verifies the existing Google Chrome prerequisite/secure installer contract. `chrome_runtime.py`, `chrome_installer.py`, `windows_authenticode.py`, browser policy, settings defaults, licensing, power management, dependencies, CI and portable packaging remain frozen. +- External Plugin API remains version 1. + +## Acceptance boundary + +Automated verification is recorded in the companion verification document after code freeze. Real Windows Chrome/UAC and 1/2/4-Task live acceptance remains owner evidence and is not fabricated from unit tests. diff --git a/docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md b/docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md new file mode 100644 index 0000000..7c26460 --- /dev/null +++ b/docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md @@ -0,0 +1,37 @@ +# VibraPilot v1.0.6.42 — Phase 2 Verification + +## Locked scope + +Production source changes are limited to: + +- `src/vibrapilot/backend.py` +- `src/vibrapilot/qt_app.py` +- `src/vibrapilot/task_runtime_store.py` +- `src/vibrapilot/workflow/__init__.py` +- `src/vibrapilot/workflow/plugin_loader.py` +- `src/vibrapilot/workflow/state.py` +- `src/vibrapilot/workspace_state.py` + +## Implemented contracts + +- strict-newer workflow update/replace; transactional rollback/recovery; safe Remove/Deactivate; restart-free default activation/switch; +- immutable per-Task workflow identity and simultaneous different-workflow workers; +- workspace/runtime schema-v2 workflow provenance and fail-closed legacy recovery; +- workflow-aware Reports and per-workflow Dashboard metric aggregation; +- Plugin API 1 unchanged; no new top-level page; +- existing Chrome prerequisite secure-install modules and policy byte-frozen. + +## Verification state + +Focused and historical regression gates passed during implementation. Final sealed automated evidence: + +- repository verifier: **PASS** +- full pytest: **541 passed, 6 skipped, 105 subtests passed** +- full unittest: **201 OK, 6 skipped** +- compileall: **PASS** +- `git diff --check`: **PASS** +- fresh v1.0.6.41 + Delta byte comparison: **439 governed files, 0 missing, 0 mismatch, PASS** +- Delta SHA inventory: **PASS** +- Delta-applied repository verifier: **PASS** + +The first final full pytest exposed only historical release-freeze tests that had not yet recognized the explicitly approved v1.0.6.42 successor schema/UI surfaces. Those tests were amended narrowly to consume the v1.0.6.42 scope contract; production code remained frozen, and the final full suite then passed. Windows live acceptance remains **PENDING OWNER EVIDENCE**. diff --git a/pyproject.toml b/pyproject.toml index a3fd40f..f34932d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vibrapilot" -version = "1.0.6.41" +version = "1.0.6.42" description = "VibraPilot browser automation desktop application by Vib Tools." requires-python = ">=3.12,<3.13" license = {text = "GPL-3.0-only"} diff --git a/scripts/verify_repository.py b/scripts/verify_repository.py index 35ed479..3711460 100644 --- a/scripts/verify_repository.py +++ b/scripts/verify_repository.py @@ -60,6 +60,7 @@ V10639_RUNTIME_RELIABILITY_SESSION_POLICY_SCOPE_CONTRACT = ROOT / "config" / "verification" / "v1.0.6.39_runtime_reliability_session_policy_scope.json" V10640_PHASE1_FORENSIC_CLOSURE_SCOPE_CONTRACT = ROOT / "config" / "verification" / "v1.0.6.40_phase1_forensic_closure_fix_scope.json" V10641_PHASE1_ACTIVE_PAGE_ORIGIN_CLOSURE_SCOPE_CONTRACT = ROOT / "config" / "verification" / "v1.0.6.41_phase1_active_page_origin_closure_scope.json" +V10642_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW_SCOPE_CONTRACT = ROOT / "config" / "verification" / "v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json" APP_CONFIG_ROOT = ROOT / "config" / "AppConfig" APP_CONFIG_APP = APP_CONFIG_ROOT / "app.py" @@ -429,11 +430,29 @@ def ast_contract_sha(node: ast.AST) -> str: ) v10641_worker_methods = set(v10641_scope.get("authorized_automationworker_method_changes", [])) +# v1.0.6.42 completes the explicitly approved Phase 2 workflow lifecycle and +# true per-Task multiworkflow scope while keeping Chrome and other frozen systems unchanged. +if not V10642_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW_SCOPE_CONTRACT.is_file(): + fail("v1.0.6.42 Phase 2 workflow lifecycle/multiworkflow scope contract is missing") +try: + v10642_scope = json.loads( + V10642_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW_SCOPE_CONTRACT.read_text(encoding="utf-8") + ) +except Exception as exc: + fail(f"v1.0.6.42 Phase 2 scope contract is invalid: {exc}") +v10642_production_allowed = set(v10642_scope.get("allowed_production_source_changes", [])) +v10642_allowed_files = ( + v10642_production_allowed + | set(v10642_scope.get("allowed_runtime_config_changes", [])) + | set(v10642_scope.get("authorized_nonproduction_files", [])) +) +v10642_worker_methods = set(v10642_scope.get("authorized_automationworker_method_changes", [])) + current_worker_methods = ( - v10630_worker_methods | v10631_worker_methods | v10632_worker_methods | v10633_worker_methods | v10635_worker_methods | v10636_worker_methods | v10639_worker_methods | v10640_worker_methods | v10641_worker_methods + v10630_worker_methods | v10631_worker_methods | v10632_worker_methods | v10633_worker_methods | v10635_worker_methods | v10636_worker_methods | v10639_worker_methods | v10640_worker_methods | v10641_worker_methods | v10642_worker_methods ) current_allowed_files = ( - v10630_allowed_files | v10631_allowed_files | v10632_allowed_files | v10633_allowed_files | v10634_allowed_files | v10635_allowed_files | v10636_allowed_files | v10637_allowed_files | v10638_allowed_files | v10639_allowed_files | v10640_allowed_files | v10641_allowed_files + v10630_allowed_files | v10631_allowed_files | v10632_allowed_files | v10633_allowed_files | v10634_allowed_files | v10635_allowed_files | v10636_allowed_files | v10637_allowed_files | v10638_allowed_files | v10639_allowed_files | v10640_allowed_files | v10641_allowed_files | v10642_allowed_files ) if pr08_allowed_files != { "src/vibrapilot/workflow_inputs.py", @@ -1830,12 +1849,16 @@ def ast_contract_sha(node: ast.AST) -> str: "def make_workflows_page(self) -> QWidget:", "self.workflow_catalog.list_workflows()", "self.workflow_catalog.require_runtime_factory(manifest.workflow_id)", - "self.request_workflow_switch(workflow_id)", 'elif name == "Workflows":', "self.refresh_workflow_showcase()", ): if marker not in qt_text: fail(f"v1.0.6.24 PR-07 UI integration marker missing: {marker}") +if "src/vibrapilot/qt_app.py" in v10642_production_allowed: + if "self.request_default_workflow_switch(resolved)" not in qt_text: + fail("v1.0.6.42 restart-free workflow activation integration marker missing") +elif "self.request_workflow_switch(workflow_id)" not in qt_text: + fail("v1.0.6.24 PR-07 workflow-switch integration marker missing") registry_text = (SRC / "workflow" / "registry.py").read_text(encoding="utf-8") if v10636_scope.get("target_version") == "1.0.6.36": if "return ()" not in registry_text or "SHARE_INVITE_MANIFEST" in registry_text or "ShareInviteWorkflow" in registry_text: @@ -1872,7 +1895,7 @@ def ast_contract_sha(node: ast.AST) -> str: "LEGACY_SHARE_INVITE_INPUT_KEYS", 'WORKFLOW_INPUT_STATE_SCHEMA_VERSION = 1', 'APP_DATA_DIR / "workflow_inputs.json"', - "workflow_input_values=self.app.current_workflow_input_snapshot()", + "workflow_input_values=self.app.current_workflow_input_snapshot(self.workflow_id)", "self.workflow_input_state_error", ): if marker not in (workflow_inputs_text_current + input_state_text_current + qt_text): @@ -1885,7 +1908,7 @@ def ast_contract_sha(node: ast.AST) -> str: 'workflow_id="share_invite"', 'WORKFLOW_INPUT_STATE_SCHEMA_VERSION = 1', 'APP_DATA_DIR / "workflow_inputs.json"', - "workflow_input_values=self.app.current_workflow_input_snapshot()", + "workflow_input_values=self.app.current_workflow_input_snapshot(self.workflow_id)", "self.workflow_input_state_error", ): if marker not in (workflow_inputs_text_current + input_state_text_current + qt_text): @@ -1956,16 +1979,17 @@ def ast_contract_sha(node: ast.AST) -> str: path = ROOT / relative if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != expected_sha: fail(f"v1.0.6.26 PR-09 frozen production/runtime drift detected: {relative}") -for marker in ( - 'SCHEMA_VERSION = 1', - 'CREATE TABLE IF NOT EXISTS runs', - 'CREATE TABLE IF NOT EXISTS items', - 'CREATE TABLE IF NOT EXISTS results', -): - if marker not in (SRC / "task_runtime_store.py").read_text(encoding="utf-8"): - fail(f"v1.0.6.26 PR-09 TaskRuntimeStore compatibility marker missing: {marker}") -if "workflow_id" in (SRC / "task_runtime_store.py").read_text(encoding="utf-8"): - fail("v1.0.6.26 PR-09 must not add workflow_id to TaskRuntimeStore") +if "src/vibrapilot/task_runtime_store.py" not in v10642_production_allowed: + for marker in ( + 'SCHEMA_VERSION = 1', + 'CREATE TABLE IF NOT EXISTS runs', + 'CREATE TABLE IF NOT EXISTS items', + 'CREATE TABLE IF NOT EXISTS results', + ): + if marker not in (SRC / "task_runtime_store.py").read_text(encoding="utf-8"): + fail(f"v1.0.6.26 PR-09 TaskRuntimeStore compatibility marker missing: {marker}") + if "workflow_id" in (SRC / "task_runtime_store.py").read_text(encoding="utf-8"): + fail("v1.0.6.26 PR-09 must not add workflow_id to TaskRuntimeStore") for marker in ( 'Path(str(TASK_RUNTIME_DB) + "-wal")', 'Path(str(TASK_RUNTIME_DB) + "-shm")', @@ -2060,7 +2084,7 @@ def ast_contract_sha(node: ast.AST) -> str: 'self.workflow_runtime_error', 'def request_workflow_state_recovery(', 'button("Recover Workflow Inputs"', - 'runtime_error = self._refresh_workflow_runtime_error()', + 'self._refresh_workflow_runtime_error()', ): if marker not in (recovery_text + (SRC / "workflow" / "state.py").read_text(encoding="utf-8") + (SRC / "workflow" / "input_state.py").read_text(encoding="utf-8") + qt_text): fail(f"v1.0.6.27 PR-10 recovery marker missing: {marker}") @@ -2190,7 +2214,7 @@ def ast_contract_sha(node: ast.AST) -> str: if v10630_scope.get(key) != expected: fail(f"v1.0.6.30 Workflow Plugin System boundary mismatch: {key}") for relative in v10630_scope.get("frozen_runtime_surfaces", []): - if relative in v10636_allowed_files: + if relative in current_allowed_files: continue path = ROOT / relative if not path.is_file(): @@ -2579,8 +2603,9 @@ def ast_contract_sha(node: ast.AST) -> str: fail("v1.0.6.37 general CI workflow must remain byte-frozen") if ci_stability.get("production_task_runtime_store_changed") is not False: fail("v1.0.6.37 CI stability correction must not change TaskRuntimeStore production code") -if sha256(SRC / "task_runtime_store.py") != v10637_scope.get("frozen_task_runtime_store_sha256"): - fail("v1.0.6.37 TaskRuntimeStore production source changed during CI stability correction") +if "src/vibrapilot/task_runtime_store.py" not in v10642_production_allowed: + if sha256(SRC / "task_runtime_store.py") != v10637_scope.get("frozen_task_runtime_store_sha256"): + fail("v1.0.6.37 TaskRuntimeStore production source changed during CI stability correction") portable_requirements = (ROOT / "requirements-portable.txt").read_text(encoding="utf-8") portable_builder = (ROOT / "scripts" / "packaging" / "build_portable_nuitka.py").read_text(encoding="utf-8") portable_verifier = (ROOT / "scripts" / "packaging" / "verify_portable_release.py").read_text(encoding="utf-8") @@ -2647,7 +2672,7 @@ def ast_contract_sha(node: ast.AST) -> str: }: fail("v1.0.6.38 portable architecture invariant mismatch") for relative, expected in v10638_scope.get("frozen_file_sha256", {}).items(): - if relative in v10639_allowed_files: + if relative in current_allowed_files: continue if sha256(ROOT / relative) != expected: fail(f"v1.0.6.38 frozen file changed outside runtime-root scope: {relative}") @@ -2678,6 +2703,8 @@ def ast_contract_sha(node: ast.AST) -> str: if set(v10639_scope.get("allowed_runtime_config_changes", [])) != {"config/settings.defaults.json"}: fail("v1.0.6.39 Phase 1 runtime config scope mismatch") for relative, expected_sha in v10639_scope.get("frozen_file_sha256", {}).items(): + if relative in v10642_production_allowed: + continue path = ROOT / relative if not path.is_file() or sha256(path) != expected_sha: fail(f"v1.0.6.39 frozen surface drift detected: {relative}") @@ -2723,6 +2750,8 @@ def ast_contract_sha(node: ast.AST) -> str: if v10640_scope.get("allowed_runtime_config_changes", []) != []: fail("v1.0.6.40 must not alter the v1.0.6.39 runtime settings policy") for relative, expected_sha in v10640_scope.get("frozen_file_sha256", {}).items(): + if relative in v10642_production_allowed: + continue path = ROOT / relative if not path.is_file() or sha256(path) != expected_sha: fail(f"v1.0.6.40 frozen surface drift detected: {relative}") @@ -2768,6 +2797,8 @@ def ast_contract_sha(node: ast.AST) -> str: if v10641_scope.get("allowed_runtime_config_changes", []) != []: fail("v1.0.6.41 must not alter runtime settings") for relative, expected_sha in v10641_scope.get("frozen_file_sha256", {}).items(): + if relative in v10642_production_allowed: + continue path = ROOT / relative if not path.is_file() or sha256(path) != expected_sha: fail(f"v1.0.6.41 frozen surface drift detected: {relative}") @@ -2779,6 +2810,96 @@ def ast_contract_sha(node: ast.AST) -> str: if marker not in backend_text: fail(f"v1.0.6.41 default-port origin canonicalization marker missing: {marker}") +# v1.0.6.42 Phase 2 workflow lifecycle + true multiworkflow exact scope. +if v10642_scope.get("plan_id") != "VP-V10642-PHASE2-WORKFLOW-LIFECYCLE-MULTIWORKFLOW-001": + fail("v1.0.6.42 Phase 2 plan identifier mismatch") +if v10642_scope.get("baseline_version") != "1.0.6.41" or v10642_scope.get("target_version") != "1.0.6.42": + fail("v1.0.6.42 Phase 2 version boundary mismatch") +if v10642_scope.get("baseline_commit") != "615fe1148431b90334e9ff3f9ae02b37a36bd1d8": + fail("v1.0.6.42 baseline commit mismatch") +if v10642_scope.get("baseline_tree") != "a6cb42814d7ed993ff5961823cf681e0cb0c0252": + fail("v1.0.6.42 baseline tree mismatch") +if v10642_scope.get("baseline_zip_sha256") != "9296626e20076a5ded1a2c6b854ce25489b09be9d8fb204061eba14612642982": + fail("v1.0.6.42 baseline ZIP hash mismatch") +expected_v10642_production = { + "src/vibrapilot/backend.py", + "src/vibrapilot/qt_app.py", + "src/vibrapilot/task_runtime_store.py", + "src/vibrapilot/workflow/__init__.py", + "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workflow/state.py", + "src/vibrapilot/workspace_state.py", +} +if v10642_production_allowed != expected_v10642_production: + fail("v1.0.6.42 production source scope mismatch") +if v10642_worker_methods != {"report_row"}: + fail("v1.0.6.42 AutomationWorker method scope mismatch") +if v10642_scope.get("allowed_runtime_config_changes", []) != []: + fail("v1.0.6.42 must not change runtime settings defaults") +if v10642_scope.get("plugin_api_version") != 1: + fail("v1.0.6.42 must preserve external Workflow Plugin API 1") +if v10642_scope.get("new_top_level_ui_pages") is not False: + fail("v1.0.6.42 must not add a top-level navigation page") +if v10642_scope.get("chrome_implementation_changes") != 0: + fail("v1.0.6.42 Chrome implementation must remain frozen unless a proven defect exists") +for relative, expected_sha in v10642_scope.get("frozen_file_sha256", {}).items(): + path = ROOT / relative + if not path.is_file() or sha256(path) != expected_sha: + fail(f"v1.0.6.42 frozen surface drift detected: {relative}") + +plugin_loader_v10642 = (SRC / "workflow" / "plugin_loader.py").read_text(encoding="utf-8") +workflow_state_v10642 = (SRC / "workflow" / "state.py").read_text(encoding="utf-8") +workspace_v10642 = (SRC / "workspace_state.py").read_text(encoding="utf-8") +runtime_store_v10642 = (SRC / "task_runtime_store.py").read_text(encoding="utf-8") +for marker in ( + "WORKFLOW_LIFECYCLE_TRANSACTION_SCHEMA_VERSION = 1", + "def recover_workflow_lifecycle_transactions", + "def update_workflow_package", + "def remove_installed_workflow", + "compare_workflow_versions", +): + if marker not in plugin_loader_v10642: + fail(f"v1.0.6.42 workflow lifecycle marker missing: {marker}") +if "def commit_default_workflow" not in workflow_state_v10642: + fail("v1.0.6.42 restart-free default workflow state commit is missing") +if literal_assignment(SRC / "workspace_state.py", "WORKSPACE_STATE_SCHEMA_VERSION") != 2: + fail("v1.0.6.42 workspace schema must be 2") +if '"workflow_id"' not in workspace_v10642 or "migration_blocked" not in workspace_v10642: + fail("v1.0.6.42 workspace workflow provenance/fail-closed migration markers are missing") +if literal_assignment(SRC / "task_runtime_store.py", "SCHEMA_VERSION") != 2: + fail("v1.0.6.42 TaskRuntimeStore schema must be 2") +if 'workflow_id TEXT NOT NULL DEFAULT' not in runtime_store_v10642: + fail("v1.0.6.42 TaskRuntimeStore workflow provenance column is missing") +for marker in ( + "active_workflow_id=self.workflow_id", + "for_active_workflow(self.workflow_id)", + "def request_default_workflow_switch", + "self.report_workflow", + 'display["workflow_id"] = "Legacy / Unknown"', + 'f"Workflow: {workflow_name} • v{workflow_version}"', +): + if marker not in qt_text: + fail(f"v1.0.6.42 Task/UI multiworkflow marker missing: {marker}") +if "active_workflow_id=self.app.active_workflow_id" in qt_text: + fail("v1.0.6.42 worker creation must not use the App-global workflow identity") +if literal_assignment(SRC / "workflow" / "schemas.py", "WORKFLOW_PLUGIN_API_VERSION") != 1: + fail("v1.0.6.42 Workflow Plugin API changed unexpectedly") +if literal_assignment(ROOT / "src" / "vibrapilot" / "qt_app.py", "NAV_SECTIONS") != [ + "Dashboard", "Tasks", "Workflows", "Workflow Inputs", "Workflow Settings", + "Reports", "Live Logs", "App Settings", "Browser Settings", "About", +]: + fail("v1.0.6.42 top-level navigation drift detected") +for marker in ( + "GOOGLE_CHROME_ENTERPRISE_MSI_URL", + 'GOOGLE_CHROME_EXPECTED_PUBLISHER = "Google LLC"', + "verify_google_chrome_installer", + "run_google_chrome_installer", +): + if marker not in (SRC / "chrome_installer.py").read_text(encoding="utf-8"): + fail(f"v1.0.6.42 frozen Chrome secure-install contract marker missing: {marker}") +if 'launch_args["channel"] = "chromium"' in backend_text: + fail("v1.0.6.42 must not reintroduce Chromium fallback") + app_version = literal_assignment(APP_CONFIG_APP, "VERSION") app_id = literal_assignment(APP_CONFIG_APP, "APP_ID") app_name = literal_assignment(APP_CONFIG_APP, "APP_NAME") @@ -2788,8 +2909,8 @@ def ast_contract_sha(node: ast.AST) -> str: license_identifier = literal_assignment(APP_CONFIG_APP, "LICENSE_IDENTIFIER") homepage_url = literal_assignment(APP_CONFIG_APP, "HOMEPAGE_URL") repository_url = literal_assignment(APP_CONFIG_APP, "REPOSITORY_URL") -if app_version != "1.0.6.41": - fail("AppConfig VERSION must be 1.0.6.41 for the Phase 1 active-page origin closure candidate") +if app_version != "1.0.6.42": + fail("AppConfig VERSION must be 1.0.6.42 for the Phase 2 workflow lifecycle/multiworkflow candidate") for name, value in { "APP_ID": app_id, "APP_NAME": app_name, @@ -2954,8 +3075,9 @@ def ast_contract_sha(node: ast.AST) -> str: fail("v1.0.6.14 managed persistent browser default must be enabled") if settings_defaults.get("restore_previous_session") is not False: fail("v1.0.6.14 must not enable previous-tab/session restoration by default") -if literal_assignment(SRC / "task_runtime_store.py", "SCHEMA_VERSION") != 1: - fail("v1.0.6.14 must preserve TaskRuntimeStore SCHEMA_VERSION = 1") +if "src/vibrapilot/task_runtime_store.py" not in v10642_production_allowed: + if literal_assignment(SRC / "task_runtime_store.py", "SCHEMA_VERSION") != 1: + fail("v1.0.6.14 must preserve TaskRuntimeStore SCHEMA_VERSION = 1") production_runtime_store = SRC / "task_runtime_store.py" if not production_runtime_store.is_file(): fail("VP-PROD-MT-LR-001 task runtime store module is missing") @@ -3556,6 +3678,7 @@ def ast_contract_sha(node: ast.AST) -> str: "config/verification/v1.0.6.39_runtime_reliability_session_policy_scope.json", "config/verification/v1.0.6.40_phase1_forensic_closure_fix_scope.json", "config/verification/v1.0.6.41_phase1_active_page_origin_closure_scope.json", + "config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json", "src/vibrapilot/power_management.py", "src/vibrapilot/chrome_runtime.py", "src/vibrapilot/chrome_installer.py", @@ -3563,6 +3686,10 @@ def ast_contract_sha(node: ast.AST) -> str: "src/vibrapilot/app_config.py", "src/vibrapilot/backend.py", "src/vibrapilot/licensing_v2.py", "src/vibrapilot/data_io.py", "src/vibrapilot/task_runtime_store.py", "src/vibrapilot/qt_app.py", "src/vibrapilot/workflow_inputs.py", "src/vibrapilot/workflow/input_state.py", "src/vibrapilot/workflow/recovery.py", "src/vibrapilot/workspace_state.py", "src/vibrapilot/browser_capabilities.py", "tests/test_v10641_phase1_active_page_origin_closure.py", + "tests/test_v10642_workflow_lifecycle.py", + "tests/test_v10642_multiworkflow_identity.py", + "tests/test_v10642_multiworkflow_persistence.py", + "tests/test_v10642_chrome_final_acceptance_contract.py", "src/vibrapilot/workflow/plugin_loader.py", "src/vibrapilot/workflow/schemas.py", "src/vibrapilot/workflow/settings_state.py", "src/vibrapilot/workflow/task_state.py", "config/verification/backend_v1.0.6_contract.json", "docs/index.md", "docs/updates/v1.0.6.31-chrome-only-runtime-foundation.md", @@ -3589,6 +3716,8 @@ def ast_contract_sha(node: ast.AST) -> str: "docs/verification/V1.0.6.40_PHASE1_FORENSIC_CLOSURE_FIX.md", "docs/updates/v1.0.6.41-phase1-active-page-origin-closure.md", "docs/verification/V1.0.6.41_PHASE1_ACTIVE_PAGE_ORIGIN_CLOSURE.md", + "docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md", + "docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md", "scripts/diagnostics/verify_v10633_browser_forensic_closure.py", "scripts/diagnostics/verify_v10634_ui_compact_polish.py", "scripts/diagnostics/verify_v10635_workflow_scoped_test_safety.py", diff --git a/src/vibrapilot/backend.py b/src/vibrapilot/backend.py index ac6301e..c59d69f 100644 --- a/src/vibrapilot/backend.py +++ b/src/vibrapilot/backend.py @@ -1721,9 +1721,9 @@ def __init__( self._pending_file_chooser_request_id: str | None = None self._pending_file_chooser_page_id: int | None = None self.browser_launch_diagnostics: dict[str, Any] = {} - # PR-06: the application owns the persisted active workflow identity and - # injects it into each worker. None/unknown values remain fail-closed at - # the existing PR-05 Master Workflow Gate; no Share Invite fallback lives + # Phase 2: each Task owns an immutable workflow identity and injects a + # manager clone bound to that identity. None/unknown values remain fail-closed + # at the existing Master Workflow Gate; no workflow-specific fallback lives # inside AutomationWorker. if workflow_manager is not None: requested_workflow_id = ( @@ -4794,6 +4794,7 @@ def report_row(self, item: TaskItem, message: str, item_index: int | None = None return { "timestamp": now_str(), "slot_id": self.state.slot_id, + "workflow_id": self._workflow_manager.active_workflow_id or "", "email": item.email, "status": item.status, "message": message, diff --git a/src/vibrapilot/qt_app.py b/src/vibrapilot/qt_app.py index 194ce71..84eb838 100644 --- a/src/vibrapilot/qt_app.py +++ b/src/vibrapilot/qt_app.py @@ -141,6 +141,10 @@ default_workflow_plugin_root, inspect_workflow_package, install_workflow_package, + compare_workflow_versions, + update_workflow_package, + remove_installed_workflow, + recover_workflow_lifecycle_transactions, normalize_form_values, normalize_task_values, ) @@ -1009,7 +1013,7 @@ def closeEvent(self, event: QCloseEvent) -> None: # type: ignore[override] class TaskSlotWidget(QFrame): """One independent browser/task slot using the frozen Vib Tools card contract.""" - def __init__(self, app: "MainWindow", slot_id: int) -> None: + def __init__(self, app: "MainWindow", slot_id: int, workflow_id: str | None = None) -> None: super().__init__() self.setObjectName("Card") self.app = app @@ -1021,7 +1025,7 @@ def __init__(self, app: "MainWindow", slot_id: int) -> None: self.browser_action_button: QPushButton | None = None # Preserve lightweight Qt-host compatibility without inventing an active # production workflow when the application has none. - self.workflow_id = str(getattr(app, "active_workflow_id", "") or "compatibility_host") + self.workflow_id = str(workflow_id or getattr(app, "active_workflow_id", "") or "compatibility_host") workflow_catalog = getattr(app, "workflow_catalog", None) workflow_task_state_store = getattr(app, "workflow_task_state_store", None) if workflow_catalog is None or workflow_task_state_store is None: @@ -1220,6 +1224,18 @@ def _build(self) -> None: text_col = QWidget() text_lay = vbox(text_col, spacing=2) text_lay.addWidget(title(f"Task {self.slot_id}", "CardTitle")) + try: + workflow_manifest = self.app.workflow_catalog.require_workflow(self.workflow_id) + workflow_name = workflow_manifest.name + workflow_version = workflow_manifest.version + except Exception: + workflow_name = self.workflow_id + workflow_version = "?" + workflow_identity = label( + f"Workflow: {workflow_name} • v{workflow_version}", "Caption", False + ) + workflow_identity.setObjectName("TaskSubtitle") + text_lay.addWidget(workflow_identity) header_lay.addWidget(text_col, 1) control_group = QWidget() @@ -1651,16 +1667,16 @@ def open_browser(self) -> None: self.pause_event, initial_url=url, runtime_store=self.app.runtime_store, - active_workflow_id=self.app.active_workflow_id, - workflow_input_values=self.app.current_workflow_input_snapshot(), - workflow_settings_values=self.app.current_workflow_settings_snapshot(), + active_workflow_id=self.workflow_id, + workflow_input_values=self.app.current_workflow_input_snapshot(self.workflow_id), + workflow_settings_values=self.app.current_workflow_settings_snapshot(self.workflow_id), workflow_task_values=dict(self.workflow_task_values), workflow_item_payloads=( list(self.workflow_item_payloads) if self.workflow_item_payloads else ([dict(self.workflow_task_values)] if self.task_schema.single_item else []) ), - workflow_manager=self.app.workflow_catalog.for_active_workflow(self.app.active_workflow_id), + workflow_manager=self.app.workflow_catalog.for_active_workflow(self.workflow_id), ) self.worker.start() self.app.log_ui(f"Task {self.slot_id}: opening browser session for workflow {self.workflow_id}") @@ -1737,6 +1753,7 @@ def _load_data_path(self, path: Path) -> None: self.state.created_at = now_str() self.state.run_id = self.app.runtime_store.start_run( slot_id=self.slot_id, + workflow_id=self.workflow_id, target_url=self.url.text().strip(), source_file=str(path), source_fingerprint=fingerprint, @@ -1835,6 +1852,7 @@ def start(self) -> None: if not self.state.run_id: self.state.run_id = self.app.runtime_store.start_run( slot_id=self.slot_id, + workflow_id=self.workflow_id, target_url=self.url.text().strip(), source_file="", source_fingerprint="", @@ -1962,6 +1980,7 @@ def _persist_closed_task(self) -> None: if not self.state.run_id: self.state.run_id = self.app.runtime_store.start_run( slot_id=self.slot_id, + workflow_id=self.workflow_id, target_url=self.state.target_url, source_file=self.state.source_file, source_fingerprint=self.state.source_fingerprint, @@ -2128,6 +2147,14 @@ def __init__(self) -> None: self.settings = SettingsManager(SETTINGS_FILE) super().__init__() self.workflow_plugin_root = default_workflow_plugin_root(APP_DATA_DIR) + self.workflow_lifecycle_error = "" + self._workflow_lifecycle_recovery_actions: list[str] = [] + try: + self._workflow_lifecycle_recovery_actions = recover_workflow_lifecycle_transactions( + self.workflow_plugin_root + ) + except WorkflowPluginError as exc: + self.workflow_lifecycle_error = str(exc) self.workflow_catalog = WorkflowManager.with_available_workflows( workflow_root=self.workflow_plugin_root ) @@ -2203,7 +2230,10 @@ def __init__(self) -> None: self.license_manager = LicenseManager(self.settings) self.runtime_store = TaskRuntimeStore(TASK_RUNTIME_DB) - self.workspace_store = WorkspaceStateStore(APP_STATE_FILE) + self.workspace_store = WorkspaceStateStore( + APP_STATE_FILE, + legacy_workflow_resolver=self._resolve_legacy_workspace_workflow_identity, + ) self.ui_queue: queue.Queue = queue.Queue(maxsize=UI_QUEUE_CAPACITY) self.tasks: dict[int, TaskSlotWidget] = {} self.next_slot_id = 1 @@ -2398,6 +2428,7 @@ def schedule_workspace_save(self) -> None: or self._workspace_transitioning or self._workspace_restore_in_progress or self._workflow_switch_in_progress + or self.workspace_store.migration_blocked ): return self.workspace_save_timer.start() @@ -2407,6 +2438,7 @@ def _workspace_snapshot(self) -> dict[str, Any]: active_tasks = [ { "slot_id": int(task.slot_id), + "workflow_id": task.workflow_id, "run_id": str(task.state.run_id or ""), "target_url": task.url.text().strip(), } @@ -2426,12 +2458,44 @@ def _workspace_snapshot(self) -> dict[str, Any]: }, } + def _resolve_legacy_workspace_workflow_identity( + self, slot_id: int, run_id: str + ) -> str | None: + """Resolve legacy v1 workspace Task identity without arbitrary guessing.""" + candidates: list[str] = [] + try: + state = self.workflow_task_state_store.load_existing() + entry = state.get("tasks", {}).get(str(max(1, int(slot_id))), {}) + workflow_id = str(entry.get("workflow_id", "") or "").strip() + if workflow_id: + candidates.append(workflow_id) + except Exception: + pass + if run_id: + try: + run = self.runtime_store.load_run(str(run_id)) + workflow_id = str((run or {}).get("workflow_id", "") or "").strip() + if workflow_id: + candidates.append(workflow_id) + except Exception: + pass + if candidates: + first = candidates[0] + if all(value == first for value in candidates) and self.workflow_catalog.get_workflow(first) is not None: + return first + return None + default_id = str(self.active_workflow_id or "").strip() + if default_id and self.workflow_catalog.get_workflow(default_id) is not None: + return default_id + return None + def save_workspace_state(self) -> bool: """Persist lightweight active-workspace metadata without duplicating Task data.""" if ( not self._workspace_active or self._workspace_restore_in_progress or self._workflow_switch_in_progress + or self.workspace_store.migration_blocked ): return False if self.workspace_save_timer.isActive(): @@ -2505,14 +2569,21 @@ def _restore_active_workspace_tasks(self, state: dict[str, Any]) -> None: continue if slot_id <= 0: continue + workflow_id = str(entry.get("workflow_id", "") or "").strip() run_id = str(entry.get("run_id", "") or "") target_url = str(entry.get("target_url", "") or "") + if not workflow_id or self.workflow_catalog.get_workflow(workflow_id) is None: + self.log_ui( + f"Task {slot_id}: workspace restore blocked because workflow {workflow_id or ''!r} is unavailable.", + "WARNING", + ) + continue if run_id and run_id in closed_run_ids: self.log_ui( f"Task {slot_id}: workspace restore skipped because it is deliberately Closed." ) continue - slot = self._add_task_with_id(slot_id) + slot = self._add_task_with_id(slot_id, workflow_id) if slot is None: continue if run_id: @@ -2568,7 +2639,6 @@ def show_workspace(self) -> None: if ( not self.workflow_recovery_error and not self.workflow_state_error - and self.active_workflow_id ): self._restore_active_workspace_tasks(workspace_state) @@ -2582,8 +2652,15 @@ def show_workspace(self) -> None: initial_slots = max(1, int(self.settings.get( "browser_slot_default", DEFAULT_SETTINGS["browser_slot_default"] ))) + # Preserve baseline first-workspace behavior: auto-created slots bind + # directly to the persisted default and never prompt repeatedly. + closed_slots = set(self.runtime_store.closed_slot_ids()) + candidate = max(1, int(self.next_slot_id)) for _ in range(initial_slots): - self.add_task() + while candidate in self.tasks or candidate in closed_slots: + candidate += 1 + self._add_task_with_id(candidate, self.active_workflow_id) + candidate += 1 selected_page = ( str(workspace_state.get("selected_page", "Dashboard")) @@ -2595,9 +2672,28 @@ def show_workspace(self) -> None: self.navigate(selected_page) self._workspace_active = True self.log_ui("License validated. Main workspace loaded.") + for action in self._workflow_lifecycle_recovery_actions: + self.log_ui(f"Workflow lifecycle recovery: {action}", "WARNING") + self._workflow_lifecycle_recovery_actions.clear() for action in self._workflow_recovery_actions: self.log_ui(f"Workflow switch recovery: {action}", "WARNING") self._workflow_recovery_actions.clear() + if self.workflow_lifecycle_error: + self.log_ui( + f"Workflow lifecycle recovery is hard-blocked: {self.workflow_lifecycle_error}", + "ERROR", + ) + QTimer.singleShot( + 0, + lambda: _message( + self, + "Workflow lifecycle blocked", + "VibraPilot detected an unresolved workflow package lifecycle transaction. " + "Workflow package mutation and browser automation are blocked until manual repair.\n\n" + f"{self.workflow_lifecycle_error}", + "error", + ), + ) if self.workflow_recovery_error: self.log_ui( f"Workflow recovery is hard-blocked: {self.workflow_recovery_error}", @@ -2659,7 +2755,6 @@ def show_workspace(self) -> None: if ( not self.workflow_recovery_error and not self.workflow_state_error - and self.active_workflow_id ): QTimer.singleShot(0, self.offer_task_recovery) finally: @@ -2985,7 +3080,7 @@ def summary_card(title_text: str, rows: list[tuple[str, str]]) -> QFrame: summary_grid.setColumnStretch(1, 1) content_lay.addWidget(summary_host) - workflow_card = card("Active Workflow") + workflow_card = card("Default Workflow") workflow_lay = workflow_card.layout() self.dashboard_workflow_name = label("", "CardTitle", False) self.dashboard_workflow_activity = label("", "Description", False) @@ -3124,6 +3219,17 @@ def _reload_workflow_catalog(self) -> None: self._refresh_workflow_runtime_error() def load_workflow_plugin(self) -> None: + if self.workflow_lifecycle_error or self._transaction_root_has_directories( + self.workflow_plugin_root / ".transactions" + ): + _message( + self, + "Workflow lifecycle blocked", + "A workflow lifecycle transaction requires recovery/manual repair before loading or updating packages. " + + (self.workflow_lifecycle_error or ""), + "warning", + ) + return package, _ = QFileDialog.getOpenFileName( self, "Load Workflow", @@ -3138,6 +3244,9 @@ def load_workflow_plugin(self) -> None: _message(self, "Workflow package invalid", str(exc), "error") return manifest = inspection.manifest + if self.workflow_catalog.get_workflow(manifest.workflow_id) is not None: + self._update_workflow_from_inspection(inspection) + return if not _confirm( self, "Trust and install workflow", @@ -3172,6 +3281,217 @@ def load_workflow_plugin(self) -> None: except Exception as exc: _message(self, "Workflow install failed", str(exc), "error") + def _workflow_reserved_ids(self) -> set[str]: + return { + item.workflow_id + for item in WorkflowManager.with_builtin_workflows().list_workflows() + } + + def _workflow_reference_block_reason(self, workflow_id: str) -> str: + resolved = str(workflow_id or "").strip() + if self.workflow_lifecycle_error: + return "Workflow lifecycle recovery is unresolved: " + self.workflow_lifecycle_error + lifecycle_root = self.workflow_plugin_root / ".transactions" + if self._transaction_root_has_directories(lifecycle_root): + return "A workflow lifecycle transaction is still present; restart or manual repair is required." + open_slots = sorted( + task.slot_id for task in self.tasks.values() if task.workflow_id == resolved + ) + if open_slots: + return "Workflow is referenced by open Task(s): " + ", ".join(str(value) for value in open_slots) + referenced_runs: list[str] = [] + rows = list(self.runtime_store.recoverable_runs()) + rows.extend( + row + for row in self.runtime_store.closed_runs() + if row.get("completed_at") is None + ) + for row in rows: + if str(row.get("workflow_id", "") or "").strip() == resolved: + run_id = str(row.get("run_id", "") or "") + if run_id: + referenced_runs.append(run_id[:12]) + if referenced_runs: + return "Workflow is referenced by recoverable/unfinished closed Task runtime(s): " + ", ".join(sorted(set(referenced_runs))) + return "" + + def _update_workflow_from_inspection( + self, inspection: Any, *, expected_workflow_id: str | None = None + ) -> None: + manifest = inspection.manifest + if expected_workflow_id and manifest.workflow_id != expected_workflow_id: + _message( + self, + "Workflow update blocked", + f"Selected package workflow ID {manifest.workflow_id!r} does not match {expected_workflow_id!r}.", + "warning", + ) + return + current = self.workflow_catalog.get_workflow(manifest.workflow_id) + if current is None: + _message( + self, + "Workflow update blocked", + f"Workflow {manifest.workflow_id!r} is not installed. Use Load Workflow instead.", + "warning", + ) + return + if compare_workflow_versions(manifest.version, current.version) <= 0: + _message( + self, + "Workflow update blocked", + f"Selected version {manifest.version} must be strictly newer than installed version {current.version}.", + "warning", + ) + return + blocker = self._workflow_reference_block_reason(manifest.workflow_id) + if blocker: + _message(self, "Workflow update blocked", blocker, "warning") + return + if not _confirm( + self, + "Trust and update workflow", + f"Workflow: {current.name} ({manifest.workflow_id})\n" + f"Installed version: {current.version}\n" + f"Selected version: {manifest.version}\n\n" + "The selected package contains executable Python workflow.py code and will replace the installed package only after validation. " + "The previous package is restored automatically if post-swap validation fails. Continue?", + ): + return + try: + updated = update_workflow_package( + inspection, + self.workflow_plugin_root, + reserved_workflow_ids=self._workflow_reserved_ids(), + ) + self._reload_workflow_catalog() + self.refresh_workflow_showcase() + self.update_dashboard() + self.log_ui( + f"Workflow plugin updated: {updated.manifest.workflow_id} v{updated.manifest.version} " + f"({inspection.package_sha256[:12]}…)." + ) + _message( + self, + "Workflow updated", + f"{updated.manifest.name} was atomically updated to v{updated.manifest.version} without restarting VibraPilot.", + ) + except Exception as exc: + _message(self, "Workflow update failed", str(exc), "error") + + def update_workflow_plugin(self, workflow_id: str) -> None: + package, _ = QFileDialog.getOpenFileName( + self, + "Update Workflow", + "", + "VibraPilot Workflow (*.vpworkflow *.zip);;All files (*.*)", + ) + if not package: + return + try: + inspection = inspect_workflow_package(Path(package)) + except Exception as exc: + _message(self, "Workflow package invalid", str(exc), "error") + return + self._update_workflow_from_inspection( + inspection, expected_workflow_id=str(workflow_id).strip() + ) + + def remove_workflow_plugin(self, workflow_id: str) -> None: + resolved = str(workflow_id or "").strip() + manifest = self.workflow_catalog.get_workflow(resolved) + if manifest is None: + _message(self, "Workflow remove blocked", "The workflow is not installed.", "warning") + return + if resolved == self.active_workflow_id: + _message( + self, + "Workflow remove blocked", + "Deactivate this default workflow before removing its installed package.", + "warning", + ) + return + blocker = self._workflow_reference_block_reason(resolved) + if blocker: + _message(self, "Workflow remove blocked", blocker, "warning") + return + if not _confirm( + self, + "Remove workflow", + f"Remove the installed executable package for {manifest.name} ({resolved})?\n\n" + "Saved Workflow Inputs/Settings and historical reports are preserved. This action does not purge user data.", + ): + return + try: + removed = remove_installed_workflow(resolved, self.workflow_plugin_root) + self._reload_workflow_catalog() + self.refresh_workflow_showcase() + self.update_dashboard() + self.log_ui(f"Workflow plugin removed: {removed.workflow_id}.") + _message(self, "Workflow removed", f"{removed.name} was removed without restarting VibraPilot.") + except Exception as exc: + _message(self, "Workflow remove failed", str(exc), "error") + + def request_default_workflow_switch(self, target_workflow_id: str | None) -> str: + """Atomically change only the default workflow for future Tasks, without restart.""" + if self.workflow_recovery_error: + raise WorkflowSwitchBlockedError( + "Workflow default change is blocked by unresolved recovery state: " + + self.workflow_recovery_error + ) + if self.workflow_lifecycle_error: + raise WorkflowSwitchBlockedError( + "Workflow default change is blocked by unresolved lifecycle state: " + + self.workflow_lifecycle_error + ) + if self.workflow_state_error: + raise WorkflowSwitchBlockedError( + "Workflow default state is unavailable; use explicit recovery before changing it." + ) + current = self.workflow_state_store.load_existing().active_workflow_id + target = str(target_workflow_id or "").strip() or None + if target == current: + return "already_active" if target is not None else "already_deactivated" + if target is not None: + error = self._workflow_runtime_error_for(target) + if error: + raise WorkflowSwitchBlockedError( + "Target workflow runtime/schema is unavailable: " + error + ) + new_state = self.workflow_state_store.commit_default_workflow( + target, expected_current_workflow_id=current + ) + self.active_workflow_id = new_state.active_workflow_id + self.workflow_input_selected_id = self.active_workflow_id or self.workflow_input_selected_id + self.workflow_settings_selected_id = self.active_workflow_id or self.workflow_settings_selected_id + self._reload_workflow_catalog() + self.refresh_workflow_showcase() + self.update_dashboard() + self.schedule_workspace_save() + if target is None: + self.log_ui("Default workflow deactivated. Existing Tasks retain their workflow identities.") + return "deactivated" + self.log_ui( + f"Default workflow changed to {target}. Existing Tasks retain their workflow identities." + ) + return "activated" if current is None else "switched" + + def deactivate_default_workflow(self, workflow_id: str) -> None: + resolved = str(workflow_id or "").strip() + if resolved != self.active_workflow_id: + self.refresh_workflow_showcase() + return + if not _confirm( + self, + "Deactivate workflow", + "Deactivate this workflow as the default for new Tasks?\n\nExisting Tasks keep their current workflow and continue to operate normally.", + ): + return + try: + self.request_default_workflow_switch(None) + except WorkflowError as exc: + _message(self, "Workflow deactivation unavailable", str(exc), "warning") + def _workflow_card(self, manifest: Any, *, active_workflow_id: str | None, state_available: bool) -> QFrame: panel = card() panel.setObjectName("WorkflowCard") @@ -3253,13 +3573,14 @@ def _workflow_card(self, manifest: Any, *, active_workflow_id: str | None, state ) action: QPushButton | None = None - if is_active and runtime_available: - action = None - elif is_active and not runtime_available: - badge.setText("UNAVAILABLE") - action = button("Unavailable", "secondary") - action.setObjectName("WorkflowUnavailableButton") - action.setEnabled(False) + if is_active: + if not runtime_available or not schema_available: + badge.setText("UNAVAILABLE") + action = button("Deactivate", "secondary") + action.setObjectName("WorkflowDeactivateButton") + action.clicked.connect( + lambda _=False, workflow_id=manifest.workflow_id: self.deactivate_default_workflow(workflow_id) + ) elif recovery_available: badge.setText("RECOVERY") action = button(f"Recover as {manifest.name}", "danger") @@ -3294,6 +3615,29 @@ def _workflow_card(self, manifest: Any, *, active_workflow_id: str | None, state action_lay.addStretch(1) if action is not None: action_lay.addWidget(action) + if origin == "plugin" and state_available: + lifecycle_blocker = self._workflow_reference_block_reason(manifest.workflow_id) + update_action = button("Update", "secondary") + update_action.setObjectName("WorkflowUpdateButton") + update_action.setEnabled(not bool(lifecycle_blocker)) + if lifecycle_blocker: + update_action.setToolTip(lifecycle_blocker) + update_action.clicked.connect( + lambda _=False, workflow_id=manifest.workflow_id: self.update_workflow_plugin(workflow_id) + ) + action_lay.addWidget(update_action) + remove_action = button("Remove", "danger") + remove_action.setObjectName("WorkflowRemoveButton") + remove_blocker = lifecycle_blocker or ( + "Deactivate this default workflow before removing it." if is_active else "" + ) + remove_action.setEnabled(not bool(remove_blocker)) + if remove_blocker: + remove_action.setToolTip(remove_blocker) + remove_action.clicked.connect( + lambda _=False, workflow_id=manifest.workflow_id: self.remove_workflow_plugin(workflow_id) + ) + action_lay.addWidget(remove_action) lay.addWidget(action_row) return panel @@ -3338,7 +3682,14 @@ def refresh_workflow_showcase(self) -> None: if state_available: self._refresh_workflow_runtime_error() - if self.workflow_recovery_error: + if self.workflow_lifecycle_error: + self.workflow_showcase_notice_title.setText("Workflow lifecycle recovery blocked") + self.workflow_showcase_notice_text.setText( + "Workflow package lifecycle operations are fail-closed until the pending transaction is repaired. " + + self.workflow_lifecycle_error + ) + self.workflow_showcase_notice.show() + elif self.workflow_recovery_error: self.workflow_showcase_notice_title.setText("Workflow recovery blocked") self.workflow_showcase_notice_text.setText( "Manual repair is required before automation or destructive workflow recovery. " @@ -3398,21 +3749,38 @@ def refresh_workflow_showcase(self) -> None: self.workflow_showcase_cards.append(empty) self._reflow_workflow_showcase() + def _confirm_default_workflow_switch(self, workflow_id: str) -> bool: + target = self.workflow_catalog.require_workflow(str(workflow_id).strip()) + current_id = str(self.active_workflow_id or "").strip() + if current_id: + try: + current_name = self.workflow_catalog.require_workflow(current_id).name + except WorkflowError: + current_name = current_id + message = ( + f"Set {target.name} as the default workflow for new Tasks instead of {current_name}?\n\n" + "Existing Tasks keep their current workflow identities and are not restarted or cleared." + ) + else: + message = ( + f"Set {target.name} as the default workflow for new Tasks?\n\n" + "Existing Tasks keep their current workflow identities and are not restarted or cleared." + ) + return _confirm(self, "Set default workflow", message) + def _activate_workflow_from_showcase(self, workflow_id: str) -> None: - """Delegate activation to the existing PR-06 switch service only.""" + """Change the default workflow for future Tasks without restarting VibraPilot.""" + resolved = str(workflow_id or "").strip() + if resolved != self.active_workflow_id and not self._confirm_default_workflow_switch(resolved): + return try: - result = self.request_workflow_switch(workflow_id) + result = self.request_default_workflow_switch(resolved) except WorkflowError as exc: self.refresh_workflow_showcase() _message(self, "Workflow activation unavailable", str(exc), "warning") return - - if result in {"already_active", "cancelled"}: + if result in {"already_active", "activated", "switched"}: self.refresh_workflow_showcase() - elif result == "committed_restart_required": - self.refresh_workflow_showcase() - elif result == "switched": - return def _recover_workflow_from_showcase(self, workflow_id: str) -> None: try: @@ -3453,6 +3821,9 @@ def make_reports_page(self) -> QWidget: self.report_search.setMinimumWidth(CONST.table_search_min_width) self.report_search.textChanged.connect(self.refresh_report_table) tl.addWidget(self.report_search, 1) + self.report_workflow = combo_box(["All Workflows"]) + self.report_workflow.currentTextChanged.connect(self.refresh_report_table) + tl.addWidget(self.report_workflow) self.report_task = combo_box(["All Tasks"]) self.report_task.currentTextChanged.connect(self.refresh_report_table) tl.addWidget(self.report_task) @@ -3466,9 +3837,9 @@ def make_reports_page(self) -> QWidget: tl.addWidget(clear_btn) lay.addWidget(toolbar) - self.report_table = QTableWidget(0, 8) + self.report_table = QTableWidget(0, 9) self.report_table.setObjectName("InvoiceProductGrid") - columns = ["timestamp", "slot_id", "email", "status", "message", "attempts", "target_url", "result"] + columns = ["timestamp", "slot_id", "workflow_id", "email", "status", "message", "attempts", "target_url", "result"] self.report_columns = columns self.report_table.setHorizontalHeaderLabels([c.replace("_", " ").title() for c in columns]) self.report_table.verticalHeader().setVisible(False) @@ -3480,8 +3851,9 @@ def make_reports_page(self) -> QWidget: header.setSectionResizeMode(i, QHeaderView.Stretch) header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.ResizeToContents) - header.setSectionResizeMode(5, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) + header.setSectionResizeMode(6, QHeaderView.ResizeToContents) lay.addWidget(self.report_table, 1) root.addWidget(content, 1) return page @@ -3905,12 +4277,23 @@ def _initialize_workflow_settings_state(self) -> None: ) self.workflow_settings_state_error = "" - def current_workflow_settings_snapshot(self) -> dict[str, Any]: - if self.workflow_settings_state_error or not self.active_workflow_id: + def current_workflow_settings_snapshot( + self, workflow_id: str | None = None + ) -> dict[str, Any]: + resolved_id = str(workflow_id or self.active_workflow_id or "").strip() + if not resolved_id: raise WorkflowSettingsStateError( - "Active Workflow Settings are unavailable; worker creation is blocked." + "Workflow Settings are unavailable; worker creation is blocked." ) - return dict(self.active_workflow_settings_values) + if resolved_id == self.active_workflow_id and not self.workflow_settings_state_error: + return dict(self.active_workflow_settings_values) + try: + state = self.workflow_settings_state_store.load_or_create() + return dict(self.workflow_settings_state_store.values_for(resolved_id, state=state)) + except (WorkflowSettingsStateError, WorkflowError) as exc: + raise WorkflowSettingsStateError( + f"Workflow Settings are unavailable for {resolved_id}: {exc}" + ) from exc def _refresh_workflow_runtime_error(self) -> str: """Preflight active runtime and declarative schemas without instantiation.""" @@ -3934,13 +4317,28 @@ def _refresh_workflow_runtime_error(self) -> str: self.workflow_runtime_error = "" return "" - def current_workflow_input_snapshot(self) -> dict[str, Any]: - """Return a detached validated snapshot for a newly created worker.""" - if self.workflow_input_state_error or not self.active_workflow_id: + def current_workflow_input_snapshot( + self, workflow_id: str | None = None + ) -> dict[str, Any]: + """Return a detached validated snapshot for one Task-owned workflow.""" + resolved_id = str(workflow_id or self.active_workflow_id or "").strip() + if not resolved_id: raise WorkflowInputStateError( - "Active Workflow Inputs are unavailable; worker creation is blocked." + "Workflow Inputs are unavailable; worker creation is blocked." + ) + if resolved_id == self.active_workflow_id and not self.workflow_input_state_error: + return dict(self.active_workflow_input_values) + try: + state = self.workflow_input_state_store.load_or_migrate( + legacy_share_invite_values=self._legacy_share_invite_input_values(), + active_workflow_id=self.active_workflow_id, + preserve_legacy_share_invite=True, ) - return dict(self.active_workflow_input_values) + return dict(self.workflow_input_state_store.values_for(resolved_id, state=state)) + except (WorkflowInputStateError, WorkflowError) as exc: + raise WorkflowInputStateError( + f"Workflow Inputs are unavailable for {resolved_id}: {exc}" + ) from exc def _workflow_input_widget( self, field: WorkflowFieldSchema, value: Any @@ -4156,13 +4554,13 @@ def make_about_page(self) -> QWidget: # ---------- task collection ---------- - def _add_task_with_id(self, slot_id: int) -> TaskSlotWidget | None: + def _add_task_with_id(self, slot_id: int, workflow_id: str | None = None) -> TaskSlotWidget | None: task_layout = self.task_layout if task_layout is None: return None if slot_id in self.tasks: return self.tasks[slot_id] - slot = TaskSlotWidget(self, slot_id) + slot = TaskSlotWidget(self, slot_id, workflow_id) insert_at = max(0, task_layout.count() - 1) task_layout.insertWidget(insert_at, slot) self.tasks[slot_id] = slot @@ -4171,19 +4569,68 @@ def _add_task_with_id(self, slot_id: int) -> TaskSlotWidget | None: self.update_dashboard() return slot - def add_task(self) -> None: - if self.workflow_state_error: - _message(self, "Workflow unavailable", self.workflow_state_error, "warning") - return - if not self.active_workflow_id: + def _select_workflow_for_new_task(self) -> str | None: + manifests = list(self.workflow_catalog.list_workflows()) + if not manifests: _message( self, - "No active workflow", - "Install and activate a workflow before creating a Task.", + "No workflows installed", + "Install a trusted workflow before creating a Task.", "warning", ) + return None + if len(manifests) == 1: + return manifests[0].workflow_id + + dialog = QDialog(self) + dialog.setWindowTitle("Select Workflow for New Task") + layout = vbox(dialog, margins=(16, 16, 16, 16), spacing=12) + layout.addWidget(label( + "Choose the workflow this Task will use. The Task workflow cannot be changed after creation.", + "Description", + False, + )) + selector = combo_box([]) + selected_index = 0 + for index, manifest in enumerate(manifests): + selector.addItem(f"{manifest.name} • v{manifest.version}", manifest.workflow_id) + if manifest.workflow_id == self.active_workflow_id: + selected_index = index + selector.setCurrentIndex(selected_index) + layout.addWidget(selector) + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + layout.addWidget(buttons) + if dialog.exec() != QDialog.Accepted: + return None + return str(selector.currentData() or "").strip() or None + + def _workflow_runtime_error_for(self, workflow_id: str) -> str: + resolved = str(workflow_id or "").strip() + if not resolved: + return "Workflow ID is unavailable." + try: + self.workflow_catalog.require_workflow(resolved) + self.workflow_catalog.require_runtime_factory(resolved) + self.workflow_catalog.input_schema(resolved) + self.workflow_catalog.settings_schema(resolved) + self.workflow_catalog.task_schema(resolved) + except WorkflowError as exc: + return str(exc) + return "" + + def add_task(self) -> None: + if self.workflow_lifecycle_error: + _message(self, "Workflow lifecycle blocked", self.workflow_lifecycle_error, "warning") return - runtime_error = self._refresh_workflow_runtime_error() + if self.workflow_state_error: + _message(self, "Workflow unavailable", self.workflow_state_error, "warning") + return + workflow_id = self._select_workflow_for_new_task() + if not workflow_id: + return + runtime_error = self._workflow_runtime_error_for(workflow_id) if runtime_error: _message(self, "Workflow unavailable", runtime_error, "warning") return @@ -4191,7 +4638,7 @@ def add_task(self) -> None: candidate = max(1, int(self.next_slot_id)) while candidate in self.tasks or candidate in closed_slots: candidate += 1 - self._add_task_with_id(candidate) + self._add_task_with_id(candidate, workflow_id) self.schedule_workspace_save() def remove_task(self, slot_id: int) -> None: @@ -4201,11 +4648,11 @@ def remove_task(self, slot_id: int) -> None: self.schedule_workspace_save() def open_closed_tasks(self) -> None: - if self.workflow_recovery_error or self.workflow_state_error or not self.active_workflow_id: + if self.workflow_recovery_error or self.workflow_state_error: _message( self, "Workflow state blocked", - "Closed Task recovery is blocked until the active workflow state is valid.", + "Closed Task recovery is blocked until workflow state is valid.", "warning", ) return @@ -4227,10 +4674,19 @@ def open_closed_tasks(self) -> None: if slot_id in self.tasks: conflicts.append(slot_id) continue + workflow_id = str(summary.get("workflow_id", "") or "").strip() + if not workflow_id: + workflow_id = str(self._resolve_legacy_workspace_workflow_identity(slot_id, run_id) or "").strip() + if not workflow_id or self.workflow_catalog.get_workflow(workflow_id) is None: + self.log_ui( + f"Task {slot_id}: Closed Task reopen blocked because workflow {workflow_id or ''!r} is unavailable.", + "WARNING", + ) + continue run = self.runtime_store.reopen_closed_run(run_id, now_str()) if not run: continue - slot = self._add_task_with_id(slot_id) + slot = self._add_task_with_id(slot_id, workflow_id) if slot is None: # Restore the archived marker if the UI could not own the slot. self.runtime_store.close_run( @@ -4539,24 +4995,37 @@ def can_open_task_browser(self, slot: TaskSlotWidget) -> tuple[bool, str]: ) if self._workflow_restart_required: return False, "Workflow change is committed. Restart VibraPilot before opening automation browsers." + if self.workflow_lifecycle_error: + return False, ( + "Workflow lifecycle recovery is unresolved. Automation is fail-closed until manual repair. " + + self.workflow_lifecycle_error + ) if self.workflow_recovery_error: return False, ( "Workflow recovery state is unresolved. Automation is hard-blocked until manual repair. " + self.workflow_recovery_error ) if self.workflow_state_error: - return False, "Active workflow state is unavailable. Automation is fail-closed until the workflow state is repaired." - if not self.active_workflow_id: - return False, "No workflow is active. Install and activate a workflow before opening automation browsers." + return False, "Workflow state is unavailable. Automation is fail-closed until the workflow state is repaired." + # Workflow input/settings stores are shared persistence roots. Preserve the + # historical fail-closed boundary if either root is known unavailable; + # per-Task snapshots below then validate the selected workflow namespace. if self.workflow_input_state_error: - return False, "Active Workflow Inputs are unavailable. Automation is fail-closed until repaired. " + self.workflow_input_state_error + return False, "Workflow Inputs are unavailable. Automation is fail-closed until repaired. " + self.workflow_input_state_error if self.workflow_settings_state_error: - return False, "Active Workflow Settings are unavailable. Automation is fail-closed until repaired. " + self.workflow_settings_state_error + return False, "Workflow Settings are unavailable. Automation is fail-closed until repaired. " + self.workflow_settings_state_error if self.workflow_task_state_error: return False, "Workflow Task state is unavailable. Automation is fail-closed until repaired. " + self.workflow_task_state_error - runtime_error = self._refresh_workflow_runtime_error() + if self.workflow_catalog.get_workflow(slot.workflow_id) is None: + return False, f"Task workflow {slot.workflow_id!r} is not installed. Browser creation is blocked." + runtime_error = self._workflow_runtime_error_for(slot.workflow_id) if runtime_error: - return False, "Active workflow runtime is unavailable. Browser creation is blocked before worker startup. " + runtime_error + return False, "Task workflow runtime is unavailable. Browser creation is blocked before worker startup. " + runtime_error + try: + self.current_workflow_input_snapshot(slot.workflow_id) + self.current_workflow_settings_snapshot(slot.workflow_id) + except (WorkflowInputStateError, WorkflowSettingsStateError) as exc: + return False, "Task workflow configuration is unavailable. Automation is fail-closed until repaired. " + str(exc) limit = max(1, int(self.settings.get("max_concurrent_tasks", DEFAULT_SETTINGS["max_concurrent_tasks"]))) active = [task for task in self.tasks.values() if task.slot_id != slot.slot_id and task.worker and task.worker.is_alive()] if len(active) >= limit: @@ -5043,7 +5512,29 @@ def request_workflow_switch(self, target_workflow_id: str) -> str: self._workflow_switch_in_progress = False raise + def _refresh_report_workflow_filter(self) -> None: + if not hasattr(self, "report_workflow"): + return + current_data = str(self.report_workflow.currentData() or "") + workflow_ids = sorted( + set(self.runtime_store.result_workflow_ids()) + | {task.workflow_id for task in self.tasks.values()} + ) + self.report_workflow.blockSignals(True) + self.report_workflow.clear() + self.report_workflow.addItem("All Workflows", "") + selected_index = 0 + for workflow_id in workflow_ids: + manifest = self.workflow_catalog.get_workflow(workflow_id) + name = manifest.name if manifest is not None else workflow_id + self.report_workflow.addItem(name, workflow_id) + if workflow_id == current_data: + selected_index = self.report_workflow.count() - 1 + self.report_workflow.setCurrentIndex(selected_index) + self.report_workflow.blockSignals(False) + def _refresh_report_task_filter(self) -> None: + self._refresh_report_workflow_filter() if not hasattr(self, "report_task"): return current = self.report_task.currentText() @@ -5056,7 +5547,7 @@ def _refresh_report_task_filter(self) -> None: self.report_task.blockSignals(False) def offer_task_recovery(self) -> None: - if self.workflow_recovery_error or self.workflow_state_error or not self.active_workflow_id: + if self.workflow_recovery_error or self.workflow_state_error: return recoverable = self.runtime_store.recoverable_runs() for summary in recoverable: @@ -5067,6 +5558,17 @@ def offer_task_recovery(self) -> None: if not run: continue slot_id = int(run.get("slot_id", 0)) + workflow_id = str(run.get("workflow_id", "") or "").strip() + if not workflow_id: + workflow_id = str( + self._resolve_legacy_workspace_workflow_identity(slot_id, run_id) or "" + ).strip() + if not workflow_id or self.workflow_catalog.get_workflow(workflow_id) is None: + self.log_ui( + f"Task {slot_id}: recovery blocked because workflow {workflow_id or ''!r} is unavailable.", + "WARNING", + ) + continue manual = bool(run.get("manual_review_required", 0)) box = QMessageBox(self) box.setIcon(QMessageBox.Warning if manual else QMessageBox.Question) @@ -5103,7 +5605,7 @@ def offer_task_recovery(self) -> None: continue if clicked is not restore: continue - slot = self._add_task_with_id(slot_id) + slot = self._add_task_with_id(slot_id, workflow_id) if slot is not None: slot.restore_runtime(run) self.log_ui(f"Task {slot_id}: recovered runtime state; browser remains closed until explicitly opened.") @@ -5937,6 +6439,12 @@ def _selected_report_slot(self) -> int | None: return None return None + def _selected_report_workflow(self) -> str | None: + if not hasattr(self, "report_workflow"): + return None + value = str(self.report_workflow.currentData() or "").strip() + return value or None + def add_report_row(self, row: dict[str, Any]) -> None: # Worker-side runtime storage is authoritative. Processing-start events stay # in Live Logs; Reports expose one latest outcome per recipient/run item. @@ -5946,6 +6454,14 @@ def add_report_row(self, row: dict[str, Any]) -> None: return self._report_dirty = True + @staticmethod + def _report_display_row(row: dict[str, Any]) -> dict[str, Any]: + """Render legacy rows without fabricating a workflow identity.""" + display = dict(row) + if not str(display.get("workflow_id", "") or "").strip(): + display["workflow_id"] = "Legacy / Unknown" + return display + def refresh_report_table(self) -> None: if not hasattr(self, "report_table"): return @@ -5953,6 +6469,7 @@ def refresh_report_table(self) -> None: search = self.report_search.text().lower() if hasattr(self, "report_search") else "" filtered = self.runtime_store.results( slot_id=self._selected_report_slot(), + workflow_id=self._selected_report_workflow(), status=status_filter, search=search, limit=REPORT_RECENT_LIMIT, @@ -5960,21 +6477,26 @@ def refresh_report_table(self) -> None: self.report_rows = filtered self.report_table.setRowCount(len(filtered)) for r, row in enumerate(filtered): + display_row = self._report_display_row(row) for c, key in enumerate(self.report_columns): - item = QTableWidgetItem(str(row.get(key, ""))) - item.setToolTip(str(row.get(key, ""))) + item = QTableWidgetItem(str(display_row.get(key, ""))) + item.setToolTip(str(display_row.get(key, ""))) self.report_table.setItem(r, c, item) self.report_table.resizeRowsToContents() def _report_export_rows(self) -> list[dict[str, Any]]: status_filter = self.report_status.currentText() if hasattr(self, "report_status") else "All" search = self.report_search.text().lower() if hasattr(self, "report_search") else "" - return self.runtime_store.results( - slot_id=self._selected_report_slot(), - status=status_filter, - search=search, - limit=None, - ) + return [ + self._report_display_row(row) + for row in self.runtime_store.results( + slot_id=self._selected_report_slot(), + workflow_id=self._selected_report_workflow(), + status=status_filter, + search=search, + limit=None, + ) + ] def export_report_csv(self) -> None: rows = self._report_export_rows() @@ -6212,21 +6734,31 @@ def update_dashboard(self) -> None: except (TypeError, ValueError): pass - task_limit = self.workflow_test_send_limit(self.active_workflow_id) usage_used = 0 usage_capacity = 0 limited_tasks = [task for task in tasks if task.task_schema.uses_test_send_limit] + configured_limits: list[int] = [] for task in limited_tasks: + configured_limit = self.workflow_test_send_limit(task.workflow_id) + configured_limits.append(configured_limit) worker = task.worker if worker is not None: - worker_limit = max(0, int(getattr(worker, "run_send_limit", task_limit))) + worker_limit = max(0, int(getattr(worker, "run_send_limit", configured_limit))) worker_used = max(0, int(getattr(worker, "run_send_count", 0))) else: - worker_limit = self.workflow_test_send_limit(task.workflow_id) + worker_limit = configured_limit worker_used = max(0, int(task.state.send_limit_used)) usage_capacity += worker_limit usage_used += min(worker_used, worker_limit) usage_available = max(0, usage_capacity - usage_used) + unique_limits = sorted(set(configured_limits)) + task_limit_text = ( + f"{unique_limits[0]} per task" + if len(unique_limits) == 1 + else "Per workflow" + if unique_limits + else "Not applicable" + ) details = { "Browsers Ready": f"{browsers_ready} / {task_count}", @@ -6246,20 +6778,20 @@ def update_dashboard(self) -> None: "Device Status": "Authorized" if license_active else "Not authorized", "Current Usage": f"{usage_used} / {usage_capacity}" if limited_tasks else "Not applicable", "Available": str(usage_available) if limited_tasks else "Not applicable", - "Task Limit": f"{task_limit} per task" if limited_tasks else "Not applicable", + "Task Limit": task_limit_text, } next_action: tuple[str, int | None] = ("tasks", None) next_message = "Review your tasks to continue." next_button = "View Tasks" if not tasks: - if not self.active_workflow_id: + if not self.workflow_catalog.list_workflows(): next_action = ("workflows", None) - next_message = "No workflow is active. Install and activate a workflow to get started." + next_message = "No workflow is installed. Load a trusted workflow to get started." next_button = "View Workflows" else: next_action = ("add", None) - next_message = "No tasks yet. Create a task to get started." + next_message = "No tasks yet. Create a task and choose its workflow." next_button = "Add Task" else: selected = next((task for task in tasks if not task.is_browser_open()), None) @@ -6313,27 +6845,52 @@ def update_dashboard(self) -> None: widget.setAccessibleName(f"{key}: {value}") widget.updateGeometry() try: - active_manifest = self.workflow_catalog.require_workflow(self.active_workflow_id or "") - workflow_name = active_manifest.name + default_manifest = self.workflow_catalog.require_workflow(self.active_workflow_id or "") + workflow_name = default_manifest.name except Exception: - workflow_name = self.active_workflow_id or "No active workflow" + workflow_name = "No default workflow" if self.dashboard_workflow_name is not None: self.dashboard_workflow_name.setText(workflow_name) if self.dashboard_workflow_activity is not None: - aggregated: dict[str, Any] = {} + workflow_groups: dict[str, list[TaskSlotWidget]] = {} for task in tasks: - for key, value in task.workflow_metrics.items(): - if isinstance(value, (int, float)) and not isinstance(value, bool): - aggregated[key] = aggregated.get(key, 0) + value - elif key not in aggregated and value not in (None, ""): - aggregated[key] = value - metric_labels = {} - try: - metric_labels = {m.key: m.label for m in self.workflow_catalog.task_schema(self.active_workflow_id or "").metrics if m.source == "workflow" and m.visible} - except Exception: - pass - lines = [f"{metric_labels.get(key, key.replace('_', ' ').title())}: {aggregated[key]}" for key in metric_labels if key in aggregated] - self.dashboard_workflow_activity.setText(" • ".join(lines) if lines else "No workflow-specific activity metrics yet.") + workflow_groups.setdefault(task.workflow_id, []).append(task) + workflow_lines: list[str] = [] + for workflow_id in sorted(workflow_groups): + group_tasks = workflow_groups[workflow_id] + manifest = self.workflow_catalog.get_workflow(workflow_id) + group_name = manifest.name if manifest is not None else workflow_id + metric_labels: dict[str, str] = {} + try: + metric_labels = { + metric.key: metric.label + for metric in self.workflow_catalog.task_schema(workflow_id).metrics + if metric.source == "workflow" and metric.visible + } + except Exception: + metric_labels = {} + aggregated: dict[str, Any] = {} + for task in group_tasks: + for key, value in task.workflow_metrics.items(): + if key not in metric_labels: + continue + if isinstance(value, (int, float)) and not isinstance(value, bool): + aggregated[key] = aggregated.get(key, 0) + value + elif key not in aggregated and value not in (None, ""): + aggregated[key] = value + values = [ + f"{metric_labels[key]}: {aggregated[key]}" + for key in metric_labels + if key in aggregated + ] + workflow_lines.append( + f"{group_name} — " + (" • ".join(values) if values else f"{len(group_tasks)} Task(s)") + ) + self.dashboard_workflow_activity.setText( + "\n".join(workflow_lines) + if workflow_lines + else "No workflow-specific activity metrics yet." + ) self.dashboard_next_action = next_action if self.dashboard_next_message is not None: self.dashboard_next_message.setText(next_message) diff --git a/src/vibrapilot/task_runtime_store.py b/src/vibrapilot/task_runtime_store.py index 1c0f8aa..28245ac 100644 --- a/src/vibrapilot/task_runtime_store.py +++ b/src/vibrapilot/task_runtime_store.py @@ -15,7 +15,8 @@ from typing import Any, Iterable -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 +LEGACY_SCHEMA_VERSION = 1 RECOVERABLE_STATUSES = { "Ready", "Running", "Paused", "Stopped", "Interrupted", "Failed", "Login/Test Mode Required", "Test Send Limit Reached", "Manual Review Required", @@ -127,6 +128,7 @@ def _ensure_schema(self) -> None: run_id TEXT PRIMARY KEY, schema_version INTEGER NOT NULL, slot_id INTEGER NOT NULL, + workflow_id TEXT NOT NULL DEFAULT '', target_url TEXT NOT NULL DEFAULT '', source_file TEXT NOT NULL DEFAULT '', source_fingerprint TEXT NOT NULL DEFAULT '', @@ -162,6 +164,7 @@ def _ensure_schema(self) -> None: item_index INTEGER NOT NULL, timestamp TEXT NOT NULL, slot_id INTEGER NOT NULL, + workflow_id TEXT NOT NULL DEFAULT '', email TEXT NOT NULL, status TEXT NOT NULL, message TEXT NOT NULL DEFAULT '', @@ -175,6 +178,24 @@ def _ensure_schema(self) -> None: ON results(slot_id, status, timestamp); """ ) + run_columns = {str(row[1]) for row in conn.execute("PRAGMA table_info(runs)").fetchall()} + if "workflow_id" not in run_columns: + conn.execute("ALTER TABLE runs ADD COLUMN workflow_id TEXT NOT NULL DEFAULT ''") + result_columns = {str(row[1]) for row in conn.execute("PRAGMA table_info(results)").fetchall()} + if "workflow_id" not in result_columns: + conn.execute("ALTER TABLE results ADD COLUMN workflow_id TEXT NOT NULL DEFAULT ''") + conn.execute( + "UPDATE runs SET schema_version=? WHERE schema_version=?", + (SCHEMA_VERSION, LEGACY_SCHEMA_VERSION), + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_runs_workflow_status " + "ON runs(workflow_id, task_status, updated_at)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_results_workflow_status " + "ON results(workflow_id, status, timestamp)" + ) @staticmethod def new_run_id() -> str: @@ -184,6 +205,7 @@ def start_run( self, *, slot_id: int, + workflow_id: str = "", target_url: str, source_file: str, source_fingerprint: str, @@ -201,12 +223,12 @@ def start_run( ) conn.execute( """INSERT INTO runs( - run_id,schema_version,slot_id,target_url,source_file,source_fingerprint, + run_id,schema_version,slot_id,workflow_id,target_url,source_file,source_fingerprint, current_index,total,success_count,failed_count,send_limit_used,task_status, manual_review_required,created_at,updated_at - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( - run_id, SCHEMA_VERSION, int(slot_id), target_url, source_file, + run_id, SCHEMA_VERSION, int(slot_id), str(workflow_id or ""), target_url, source_file, source_fingerprint, 0, len(item_rows), 0, 0, 0, "Ready", 0, created_at, created_at, ), @@ -290,15 +312,15 @@ def upsert_result(self, run_id: str, item_index: int, row: dict[str, Any]) -> No with self._write_connection() as conn: conn.execute( """INSERT INTO results( - run_id,item_index,timestamp,slot_id,email,status,message,attempts,target_url,result - ) VALUES(?,?,?,?,?,?,?,?,?,?) + run_id,item_index,timestamp,slot_id,workflow_id,email,status,message,attempts,target_url,result + ) VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,item_index) DO UPDATE SET - timestamp=excluded.timestamp,slot_id=excluded.slot_id,email=excluded.email, + timestamp=excluded.timestamp,slot_id=excluded.slot_id,workflow_id=excluded.workflow_id,email=excluded.email, status=excluded.status,message=excluded.message,attempts=excluded.attempts, target_url=excluded.target_url,result=excluded.result""", ( run_id, int(item_index), str(row.get("timestamp", "")), - int(row.get("slot_id", 0)), str(row.get("email", "")), + int(row.get("slot_id", 0)), str(row.get("workflow_id", "")), str(row.get("email", "")), str(row.get("status", "")), str(row.get("message", "")), int(row.get("attempts", 0)), str(row.get("target_url", "")), str(row.get("result", "")), @@ -345,15 +367,15 @@ def persist_item_result_progress( row = result_row conn.execute( """INSERT INTO results( - run_id,item_index,timestamp,slot_id,email,status,message,attempts,target_url,result - ) VALUES(?,?,?,?,?,?,?,?,?,?) + run_id,item_index,timestamp,slot_id,workflow_id,email,status,message,attempts,target_url,result + ) VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,item_index) DO UPDATE SET - timestamp=excluded.timestamp,slot_id=excluded.slot_id,email=excluded.email, + timestamp=excluded.timestamp,slot_id=excluded.slot_id,workflow_id=excluded.workflow_id,email=excluded.email, status=excluded.status,message=excluded.message,attempts=excluded.attempts, target_url=excluded.target_url,result=excluded.result""", ( run_id, int(item_index), str(row.get("timestamp", "")), - int(row.get("slot_id", 0)), str(row.get("email", "")), + int(row.get("slot_id", 0)), str(row.get("workflow_id", "")), str(row.get("email", "")), str(row.get("status", "")), str(row.get("message", "")), int(row.get("attempts", 0)), str(row.get("target_url", "")), str(row.get("result", "")), @@ -549,13 +571,13 @@ def skip_current_manual_review(self, run_id: str, timestamp: str) -> None: (index + 1, timestamp, run_id), ) conn.execute( - """INSERT INTO results(run_id,item_index,timestamp,slot_id,email,status,message,attempts,target_url,result) - VALUES(?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(run_id,item_index) DO UPDATE SET timestamp=excluded.timestamp,status=excluded.status, + """INSERT INTO results(run_id,item_index,timestamp,slot_id,workflow_id,email,status,message,attempts,target_url,result) + VALUES(?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(run_id,item_index) DO UPDATE SET timestamp=excluded.timestamp,workflow_id=excluded.workflow_id,status=excluded.status, message=excluded.message,attempts=excluded.attempts,result=excluded.result""", ( run_id, index, timestamp, int(data.get("slot_id", 0)), - str(item.get("email", "")), "interrupted", item["message"], + str(data.get("workflow_id", "")), str(item.get("email", "")), "interrupted", item["message"], int(item.get("attempts", 0)), str(data.get("target_url", "")), str(item.get("result", "")), ), @@ -565,6 +587,7 @@ def results( self, *, slot_id: int | None = None, + workflow_id: str | None = None, status: str | None = None, search: str = "", limit: int | None = None, @@ -574,6 +597,9 @@ def results( if slot_id is not None: clauses.append("slot_id=?") params.append(int(slot_id)) + if workflow_id and workflow_id != "All Workflows": + clauses.append("workflow_id=?") + params.append(str(workflow_id)) if status and status != "All": clauses.append("status=?") params.append(status) @@ -584,7 +610,7 @@ def results( "OR lower(target_url) LIKE ? OR lower(result) LIKE ?)" ) params.extend([needle] * 5) - sql = "SELECT timestamp,slot_id,email,status,message,attempts,target_url,result,run_id,item_index FROM results" + sql = "SELECT timestamp,slot_id,workflow_id,email,status,message,attempts,target_url,result,run_id,item_index FROM results" if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY timestamp DESC, slot_id DESC, item_index DESC" @@ -599,6 +625,13 @@ def result_slot_ids(self) -> list[int]: rows = conn.execute("SELECT DISTINCT slot_id FROM results ORDER BY slot_id").fetchall() return [int(row[0]) for row in rows] + def result_workflow_ids(self) -> list[str]: + with self._connection() as conn: + rows = conn.execute( + "SELECT DISTINCT workflow_id FROM results WHERE workflow_id != '' ORDER BY workflow_id" + ).fetchall() + return [str(row[0]) for row in rows if str(row[0]).strip()] + def clear_results(self) -> None: with self._write_connection() as conn: conn.execute("DELETE FROM results") diff --git a/src/vibrapilot/workflow/__init__.py b/src/vibrapilot/workflow/__init__.py index 217ee2c..5b55b9d 100644 --- a/src/vibrapilot/workflow/__init__.py +++ b/src/vibrapilot/workflow/__init__.py @@ -28,6 +28,11 @@ default_workflow_plugin_root, inspect_workflow_package, install_workflow_package, + compare_workflow_versions, + update_workflow_package, + remove_installed_workflow, + recover_workflow_lifecycle_transactions, + WORKFLOW_LIFECYCLE_TRANSACTION_SCHEMA_VERSION, load_installed_workflows, load_workflow_directory, ) @@ -124,6 +129,11 @@ "default_workflow_plugin_root", "inspect_workflow_package", "install_workflow_package", + "compare_workflow_versions", + "update_workflow_package", + "remove_installed_workflow", + "recover_workflow_lifecycle_transactions", + "WORKFLOW_LIFECYCLE_TRANSACTION_SCHEMA_VERSION", "load_installed_workflows", "load_workflow_directory", "WORKFLOW_SETTINGS_STATE_SCHEMA_VERSION", diff --git a/src/vibrapilot/workflow/plugin_loader.py b/src/vibrapilot/workflow/plugin_loader.py index 709b099..56c4726 100644 --- a/src/vibrapilot/workflow/plugin_loader.py +++ b/src/vibrapilot/workflow/plugin_loader.py @@ -450,6 +450,7 @@ def install_workflow_package( ) root = Path(workflow_root).expanduser().resolve() root.mkdir(parents=True, exist_ok=True) + _assert_lifecycle_idle(root) destination = root / workflow_id if destination.exists(): raise WorkflowPluginInstallError( @@ -507,6 +508,328 @@ def install_workflow_package( pass + +WORKFLOW_LIFECYCLE_TRANSACTION_SCHEMA_VERSION = 1 +_LIFECYCLE_PREPARED = "PREPARED" +_LIFECYCLE_COMMITTED = "COMMITTED" + + +def _version_tuple(value: str) -> tuple[int, int, int, int]: + parts = [int(part) for part in str(value).strip().split(".")] + if not 1 <= len(parts) <= 4: + raise WorkflowPluginInstallError(f"Workflow version is invalid: {value!r}.") + return tuple((parts + [0] * (4 - len(parts)))[:4]) # type: ignore[return-value] + + +def compare_workflow_versions(left: str, right: str) -> int: + """Compare validated numeric dotted workflow versions without lexical ordering.""" + lhs = _version_tuple(left) + rhs = _version_tuple(right) + return (lhs > rhs) - (lhs < rhs) + + +def _lifecycle_root(workflow_root: Path) -> Path: + return Path(workflow_root).expanduser().resolve() / ".transactions" + + +def _assert_lifecycle_idle(workflow_root: Path) -> None: + tx_root = _lifecycle_root(workflow_root) + if tx_root.is_dir() and any(tx_root.iterdir()): + raise WorkflowPluginInstallError( + "A pending workflow lifecycle transaction must be recovered before another package mutation." + ) + + +def _write_lifecycle_manifest(path: Path, payload: Mapping[str, Any]) -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp") + data = json.dumps(dict(payload), indent=2, sort_keys=True) + "\n" + try: + with temporary.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(data) + handle.flush() + try: + os.fsync(handle.fileno()) + except OSError: + pass + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + + +def _new_lifecycle_transaction( + workflow_root: Path, *, action: str, workflow_id: str, target_version: str = "" +) -> tuple[Path, Path]: + tx_root = _lifecycle_root(workflow_root) + tx_root.mkdir(parents=True, exist_ok=True) + tx = tx_root / f"{workflow_id}-{action}-{uuid.uuid4().hex}" + tx.mkdir(parents=False, exist_ok=False) + manifest = tx / "transaction.json" + _write_lifecycle_manifest( + manifest, + { + "schema_version": WORKFLOW_LIFECYCLE_TRANSACTION_SCHEMA_VERSION, + "status": _LIFECYCLE_PREPARED, + "action": action, + "workflow_id": workflow_id, + "target_version": target_version, + "created_at": _utc_now(), + }, + ) + return tx, manifest + + +def _commit_lifecycle_transaction(manifest: Path) -> None: + payload = json.loads(Path(manifest).read_text(encoding="utf-8")) + payload["status"] = _LIFECYCLE_COMMITTED + _write_lifecycle_manifest(manifest, payload) + + +def _cleanup_lifecycle_transaction(tx: Path) -> None: + tx = Path(tx) + if tx.exists(): + shutil.rmtree(tx, ignore_errors=False) + root = tx.parent + try: + if root.is_dir() and not any(root.iterdir()): + root.rmdir() + except OSError: + pass + + +def recover_workflow_lifecycle_transactions(workflow_root: Path) -> list[str]: + """Recover interrupted workflow update/remove transactions before catalog load.""" + root = Path(workflow_root).expanduser().resolve() + tx_root = _lifecycle_root(root) + if not tx_root.exists(): + return [] + unexpected = sorted(path.name for path in tx_root.iterdir() if not path.is_dir()) + if unexpected: + raise WorkflowPluginInstallError( + "Workflow lifecycle transaction root contains unexpected entries: " + + ", ".join(unexpected) + ) + actions: list[str] = [] + for tx in sorted((p for p in tx_root.iterdir() if p.is_dir()), key=lambda p: p.name): + manifest = tx / "transaction.json" + if not manifest.is_file(): + raise WorkflowPluginInstallError( + f"Workflow lifecycle transaction manifest is missing: {tx.name}." + ) + try: + payload = json.loads(manifest.read_text(encoding="utf-8")) + except Exception as exc: + raise WorkflowPluginInstallError( + f"Workflow lifecycle transaction manifest is invalid: {tx.name}: {exc}" + ) from exc + if int(payload.get("schema_version", 0)) != WORKFLOW_LIFECYCLE_TRANSACTION_SCHEMA_VERSION: + raise WorkflowPluginInstallError( + f"Unsupported workflow lifecycle transaction schema: {tx.name}." + ) + status = str(payload.get("status", "")) + action = str(payload.get("action", "")) + workflow_id = str(payload.get("workflow_id", "")).strip() + if action not in {"update", "remove"} or not workflow_id: + raise WorkflowPluginInstallError( + f"Workflow lifecycle transaction identity is invalid: {tx.name}." + ) + destination = root / workflow_id + backup = tx / "backup" + if status == _LIFECYCLE_PREPARED: + if backup.exists(): + if destination.exists(): + shutil.rmtree(destination, ignore_errors=False) + os.replace(backup, destination) + actions.append(f"rolled back {action} transaction for {workflow_id}") + else: + actions.append(f"cleaned uncommitted {action} transaction for {workflow_id}") + _cleanup_lifecycle_transaction(tx) + elif status == _LIFECYCLE_COMMITTED: + _cleanup_lifecycle_transaction(tx) + actions.append(f"cleaned committed {action} transaction for {workflow_id}") + else: + raise WorkflowPluginInstallError( + f"Workflow lifecycle transaction status is invalid: {tx.name}." + ) + try: + if tx_root.is_dir() and not any(tx_root.iterdir()): + tx_root.rmdir() + except OSError: + pass + return actions + + +def _extract_inspected_package_to_staging( + inspection: WorkflowPackageInspection, + staging: Path, +) -> InstalledWorkflowPlugin: + with zipfile.ZipFile(inspection.package_path) as archive: + infos, prefix = _package_members(archive) + if prefix != inspection.root_prefix: + raise WorkflowPluginInstallError("Workflow package changed after inspection.") + current_sha = _sha256(inspection.package_path) + if current_sha != inspection.package_sha256: + raise WorkflowPluginInstallError("Workflow package changed after inspection.") + for info in infos: + rel_name = info.filename[len(prefix):] if prefix else info.filename + rel = _safe_archive_name(rel_name) + target = (staging / Path(*rel.parts)).resolve() + try: + target.relative_to(staging) + except ValueError as exc: + raise WorkflowPluginInstallError("Workflow extraction escaped staging directory.") from exc + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info, "r") as src, target.open("wb") as dst: + shutil.copyfileobj(src, dst) + metadata = { + "schema_version": 1, + "workflow_id": inspection.manifest.workflow_id, + "package_sha256": inspection.package_sha256, + "installed_at": _utc_now(), + "plugin_api": inspection.plugin_api, + } + (staging / _INSTALL_METADATA).write_text( + json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + loaded = load_workflow_directory(staging) + if loaded.manifest != inspection.manifest: + raise WorkflowPluginInstallError("Staged workflow manifest differs from inspected package.") + return loaded + + +def update_workflow_package( + inspection: WorkflowPackageInspection, + workflow_root: Path, + *, + reserved_workflow_ids: set[str] | frozenset[str], +) -> InstalledWorkflowPlugin: + """Atomically replace an installed workflow with a strictly newer validated package.""" + if inspection.plugin_api != WORKFLOW_PLUGIN_API_VERSION: + raise WorkflowPluginInstallError("Workflow package inspection is no longer compatible.") + workflow_id = inspection.manifest.workflow_id + if workflow_id in set(reserved_workflow_ids): + raise WorkflowPluginInstallError(f"Workflow ID {workflow_id!r} is reserved by a built-in workflow.") + root = Path(workflow_root).expanduser().resolve() + _assert_lifecycle_idle(root) + destination = root / workflow_id + if not destination.is_dir(): + raise WorkflowPluginInstallError( + f"Workflow {workflow_id!r} is not installed; use Load Workflow instead of Update." + ) + current = load_workflow_directory(destination) + if current.manifest.workflow_id != workflow_id: + raise WorkflowPluginInstallError("Installed workflow identity does not match update package.") + if compare_workflow_versions(inspection.manifest.version, current.manifest.version) <= 0: + raise WorkflowPluginInstallError( + f"Workflow update must be strictly newer than installed version {current.manifest.version}." + ) + + staging_parent = root / ".staging" + staging_parent.mkdir(parents=True, exist_ok=True) + staging = staging_parent / f"{workflow_id}-update-{uuid.uuid4().hex}" + staging.mkdir(parents=True, exist_ok=False) + tx: Path | None = None + manifest: Path | None = None + swapped = False + try: + _extract_inspected_package_to_staging(inspection, staging) + tx, manifest = _new_lifecycle_transaction( + root, action="update", workflow_id=workflow_id, target_version=inspection.manifest.version + ) + backup = tx / "backup" + os.replace(destination, backup) + os.replace(staging, destination) + swapped = True + try: + loaded = load_workflow_directory(destination) + except Exception as exc: + if destination.exists(): + shutil.rmtree(destination, ignore_errors=True) + if backup.exists(): + os.replace(backup, destination) + raise WorkflowPluginInstallError( + f"Workflow update post-swap validation failed; previous version restored: {exc}" + ) from exc + if loaded.manifest != inspection.manifest: + if destination.exists(): + shutil.rmtree(destination, ignore_errors=True) + if backup.exists(): + os.replace(backup, destination) + raise WorkflowPluginInstallError( + "Workflow update post-swap manifest mismatch; previous version restored." + ) + _commit_lifecycle_transaction(manifest) + if backup.exists(): + shutil.rmtree(backup, ignore_errors=False) + _cleanup_lifecycle_transaction(tx) + return loaded + except WorkflowPluginError: + raise + except Exception as exc: + if swapped and tx is not None: + backup = tx / "backup" + if backup.exists(): + if destination.exists(): + shutil.rmtree(destination, ignore_errors=True) + os.replace(backup, destination) + raise WorkflowPluginInstallError(str(exc)) from exc + finally: + if staging.exists(): + shutil.rmtree(staging, ignore_errors=True) + try: + if staging_parent.is_dir() and not any(staging_parent.iterdir()): + staging_parent.rmdir() + except OSError: + pass + if tx is not None and tx.exists(): + # A failed operation that restored the old package no longer needs + # recovery staging. A committed operation already cleaned itself. + backup = tx / "backup" + if not backup.exists(): + _cleanup_lifecycle_transaction(tx) + + +def remove_installed_workflow(workflow_id: str, workflow_root: Path) -> WorkflowManifest: + """Atomically remove one installed workflow package directory only.""" + normalized = str(workflow_id).strip() + root = Path(workflow_root).expanduser().resolve() + _assert_lifecycle_idle(root) + if ( + not normalized + or normalized in {".", ".."} + or "/" in normalized + or "\\" in normalized + or ":" in normalized + ): + raise WorkflowPluginInstallError("Workflow remove ID is not a safe installed workflow identifier.") + destination = (root / normalized).resolve() + try: + destination.relative_to(root) + except ValueError as exc: + raise WorkflowPluginInstallError("Workflow remove path escapes the workflow root.") from exc + if not destination.is_dir(): + raise WorkflowPluginInstallError(f"Workflow {normalized!r} is not installed.") + current = load_workflow_directory(destination) + if current.manifest.workflow_id != normalized: + raise WorkflowPluginInstallError("Installed workflow identity does not match its directory name.") + tx, manifest = _new_lifecycle_transaction(root, action="remove", workflow_id=normalized) + backup = tx / "backup" + try: + os.replace(destination, backup) + _commit_lifecycle_transaction(manifest) + shutil.rmtree(backup, ignore_errors=False) + _cleanup_lifecycle_transaction(tx) + return current.manifest + except Exception as exc: + if backup.exists() and not destination.exists(): + os.replace(backup, destination) + if tx.exists() and not (tx / "backup").exists(): + _cleanup_lifecycle_transaction(tx) + if isinstance(exc, WorkflowPluginError): + raise + raise WorkflowPluginInstallError(str(exc)) from exc + def load_installed_workflows( workflow_root: Path, *, diff --git a/src/vibrapilot/workflow/state.py b/src/vibrapilot/workflow/state.py index e3e6b1c..540a3ff 100644 --- a/src/vibrapilot/workflow/state.py +++ b/src/vibrapilot/workflow/state.py @@ -228,13 +228,17 @@ def recover_active_workflow(self, target_workflow_id: str) -> WorkflowState: _atomic_write_json(self.path, state.to_dict()) return state - def commit_active_workflow( + def commit_default_workflow( self, - target_workflow_id: str, + target_workflow_id: str | None, *, expected_current_workflow_id: str | None, ) -> WorkflowState: - """Atomically commit a validated target, including first activation from None.""" + """Atomically change the default workflow for newly created Tasks. + + ``None`` is a legitimate deactivated/default-free state. Existing Task + workflow identities are owned elsewhere and are never mutated here. + """ current = self.load_existing() expected = ( str(expected_current_workflow_id).strip() @@ -245,7 +249,11 @@ def commit_active_workflow( raise WorkflowSwitchError( "workflow state changed during switch transaction; commit aborted" ) - target = self._validate_workflow_id(target_workflow_id) + target = ( + self._validate_workflow_id(target_workflow_id) + if target_workflow_id is not None + else None + ) state = WorkflowState( schema_version=WORKFLOW_STATE_SCHEMA_VERSION, active_workflow_id=target, @@ -255,6 +263,18 @@ def commit_active_workflow( _atomic_write_json(self.path, state.to_dict()) return state + def commit_active_workflow( + self, + target_workflow_id: str, + *, + expected_current_workflow_id: str | None, + ) -> WorkflowState: + """Compatibility wrapper for validated non-null active/default workflow commits.""" + return self.commit_default_workflow( + target_workflow_id, + expected_current_workflow_id=expected_current_workflow_id, + ) + @dataclass(frozen=True, slots=True) class WorkflowSwitchBackupEntry: diff --git a/src/vibrapilot/workspace_state.py b/src/vibrapilot/workspace_state.py index a4c0060..fef18fc 100644 --- a/src/vibrapilot/workspace_state.py +++ b/src/vibrapilot/workspace_state.py @@ -10,18 +10,26 @@ import os import uuid from pathlib import Path -from typing import Any +from typing import Any, Callable -WORKSPACE_STATE_SCHEMA_VERSION = 1 +WORKSPACE_STATE_SCHEMA_VERSION = 2 +_LEGACY_WORKSPACE_STATE_SCHEMA_VERSION = 1 class WorkspaceStateStore: - """Atomic JSON workspace metadata store with safe corruption fallback.""" - - def __init__(self, path: Path): + """Atomic JSON workspace metadata store with safe v1→v2 migration.""" + + def __init__( + self, + path: Path, + *, + legacy_workflow_resolver: Callable[[int, str], str | None] | None = None, + ): self.path = Path(path) self.warning = "" + self.migration_blocked = False + self.legacy_workflow_resolver = legacy_workflow_resolver def _quarantine(self, reason: str) -> None: if not self.path.exists(): @@ -37,7 +45,7 @@ def _quarantine(self, reason: str) -> None: self.warning = reason @staticmethod - def _normalize_task(entry: Any) -> dict[str, Any] | None: + def _normalize_task(entry: Any, *, require_workflow: bool = True) -> dict[str, Any] | None: if not isinstance(entry, dict): return None try: @@ -46,8 +54,12 @@ def _normalize_task(entry: Any) -> dict[str, Any] | None: return None if slot_id <= 0: return None + workflow_id = str(entry.get("workflow_id", "") or "").strip() + if require_workflow and not workflow_id: + return None return { "slot_id": slot_id, + "workflow_id": workflow_id, "run_id": str(entry.get("run_id", "") or ""), "target_url": str(entry.get("target_url", "") or ""), } @@ -71,9 +83,69 @@ def integer(name: str, default: int) -> int: "maximized": bool(value.get("maximized", False)), } + def _normalized_state(self, raw: dict[str, Any], *, schema_version: int) -> tuple[dict[str, Any], bool]: + migrated = schema_version == _LEGACY_WORKSPACE_STATE_SCHEMA_VERSION + tasks: list[dict[str, Any]] = [] + seen_slots: set[int] = set() + unresolved_slots: list[int] = [] + raw_tasks = raw.get("active_tasks", []) + if isinstance(raw_tasks, list): + for item in raw_tasks: + normalized = self._normalize_task( + item, + require_workflow=schema_version == WORKSPACE_STATE_SCHEMA_VERSION, + ) + if normalized is None: + continue + slot_id = int(normalized["slot_id"]) + if slot_id in seen_slots: + continue + if migrated: + resolver = self.legacy_workflow_resolver + workflow_id = ( + str(resolver(slot_id, str(normalized.get("run_id", ""))) or "").strip() + if resolver is not None + else "" + ) + if not workflow_id: + unresolved_slots.append(slot_id) + continue + normalized["workflow_id"] = workflow_id + if not str(normalized.get("workflow_id", "")).strip(): + unresolved_slots.append(slot_id) + continue + seen_slots.add(slot_id) + tasks.append(normalized) + + if unresolved_slots: + self.migration_blocked = True + self.warning = ( + "Workspace Task workflow identity could not be resolved for slot(s): " + + ", ".join(str(value) for value in sorted(set(unresolved_slots))) + + ". Those Task shells were not restored; no workflow identity was guessed." + ) + + try: + next_slot_id = max(1, int(raw.get("next_slot_id", 1))) + except (TypeError, ValueError): + next_slot_id = 1 + + return ( + { + "schema_version": WORKSPACE_STATE_SCHEMA_VERSION, + "saved_at": str(raw.get("saved_at", "") or ""), + "active_tasks": tasks, + "next_slot_id": next_slot_id, + "selected_page": str(raw.get("selected_page", "Dashboard") or "Dashboard"), + "window": self._normalize_window(raw.get("window", {})), + }, + migrated, + ) + def load(self) -> dict[str, Any] | None: - """Return normalized workspace metadata, or ``None`` for safe fallback.""" + """Return normalized workspace metadata, migrating legacy v1 state in place.""" self.warning = "" + self.migration_blocked = False if not self.path.exists(): return None try: @@ -88,43 +160,24 @@ def load(self) -> dict[str, Any] | None: schema_version = int(raw.get("schema_version", 0)) except (TypeError, ValueError): schema_version = 0 - if schema_version != WORKSPACE_STATE_SCHEMA_VERSION: + if schema_version not in { + _LEGACY_WORKSPACE_STATE_SCHEMA_VERSION, + WORKSPACE_STATE_SCHEMA_VERSION, + }: self._quarantine( f"Unsupported workspace state schema {schema_version}; expected " - f"{WORKSPACE_STATE_SCHEMA_VERSION}." + f"{_LEGACY_WORKSPACE_STATE_SCHEMA_VERSION} or {WORKSPACE_STATE_SCHEMA_VERSION}." ) return None - - tasks: list[dict[str, Any]] = [] - seen_slots: set[int] = set() - raw_tasks = raw.get("active_tasks", []) - if isinstance(raw_tasks, list): - for item in raw_tasks: - normalized = self._normalize_task(item) - if normalized is None: - continue - slot_id = int(normalized["slot_id"]) - if slot_id in seen_slots: - continue - seen_slots.add(slot_id) - tasks.append(normalized) - - try: - next_slot_id = max(1, int(raw.get("next_slot_id", 1))) - except (TypeError, ValueError): - next_slot_id = 1 - - return { - "schema_version": WORKSPACE_STATE_SCHEMA_VERSION, - "saved_at": str(raw.get("saved_at", "") or ""), - "active_tasks": tasks, - "next_slot_id": next_slot_id, - "selected_page": str(raw.get("selected_page", "Dashboard") or "Dashboard"), - "window": self._normalize_window(raw.get("window", {})), - } + state, migrated = self._normalized_state(raw, schema_version=schema_version) + if migrated and not self.migration_blocked: + warning = self.warning + self.save(state) + self.warning = warning + return state def save(self, state: dict[str, Any]) -> None: - """Atomically replace ``state.json`` with normalized metadata.""" + """Atomically replace ``state.json`` with normalized v2 metadata.""" if not isinstance(state, dict): raise TypeError("Workspace state must be a dictionary.") payload = { @@ -137,7 +190,7 @@ def save(self, state: dict[str, Any]) -> None: } seen_slots: set[int] = set() for item in state.get("active_tasks", []): - normalized = self._normalize_task(item) + normalized = self._normalize_task(item, require_workflow=True) if normalized is None: continue slot_id = int(normalized["slot_id"]) diff --git a/tests/test_app_config.py b/tests/test_app_config.py index 45fb043..9939b87 100644 --- a/tests/test_app_config.py +++ b/tests/test_app_config.py @@ -24,7 +24,7 @@ def test_authoritative_identity(self): self.assertEqual(APP.app_id, "vibrapilot") self.assertEqual(APP.app_name, "VibraPilot") self.assertEqual(APP.display_name, "VibraPilot") - self.assertEqual(APP.version, "1.0.6.41") + self.assertEqual(APP.version, "1.0.6.42") self.assertEqual(APP.owner_name, "Vib Tools") self.assertEqual(APP.license_identifier, "GPL-3.0-only") self.assertEqual(APP.updated_date, "2026-08-18") diff --git a/tests/test_pr03_workflow_contracts_registry.py b/tests/test_pr03_workflow_contracts_registry.py index 535b222..649aced 100644 --- a/tests/test_pr03_workflow_contracts_registry.py +++ b/tests/test_pr03_workflow_contracts_registry.py @@ -23,6 +23,7 @@ PR08_SCOPE = ROOT / "config" / "verification" / "v1.0.6.25_pr08_dynamic_workflow_inputs_scope.json" V10630_SCOPE = ROOT / "config" / "verification" / "v1.0.6.30_workflow_plugin_system_scope.json" V10631_SCOPE = ROOT / "config" / "verification" / "v1.0.6.31_chrome_only_browser_runtime_scope.json" +V10642_SCOPE = ROOT / "config" / "verification" / "v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json" def _manifest(workflow_id: str = "alpha") -> WorkflowManifest: @@ -170,6 +171,10 @@ def test_approved_frozen_production_files_are_byte_identical(): current = json.loads(V10631_SCOPE.read_text(encoding="utf-8")) superseded.update(current.get("allowed_production_source_changes", [])) superseded.update(current.get("authorized_nonproduction_files", [])) + if V10642_SCOPE.is_file(): + current = json.loads(V10642_SCOPE.read_text(encoding="utf-8")) + superseded.update(current.get("allowed_production_source_changes", [])) + superseded.update(current.get("authorized_nonproduction_files", [])) for relative, expected in scope["frozen_file_sha256"].items(): if relative in superseded: continue diff --git a/tests/test_v10614_managed_persistent_browser.py b/tests/test_v10614_managed_persistent_browser.py index 45555f6..92b5190 100644 --- a/tests/test_v10614_managed_persistent_browser.py +++ b/tests/test_v10614_managed_persistent_browser.py @@ -223,7 +223,9 @@ def _start(self, store: TaskRuntimeStore, *, slot_id: int = 5) -> str: def test_close_and_reopen_preserve_schema_items_progress_and_run_identity(self): with tempfile.TemporaryDirectory() as td: store = TaskRuntimeStore(Path(td) / "store.sqlite3") - self.assertEqual(SCHEMA_VERSION, 1) + # v1.0.6.42 intentionally migrates TaskRuntimeStore to schema v2 for + # workflow provenance; this test continues to protect close/reopen semantics. + self.assertEqual(SCHEMA_VERSION, 2) run_id = self._start(store) items = [ Item("a@example.com", status="success", attempts=1, result="sent"), diff --git a/tests/test_v10615_workspace_persistence.py b/tests/test_v10615_workspace_persistence.py index 889d877..41fb865 100644 --- a/tests/test_v10615_workspace_persistence.py +++ b/tests/test_v10615_workspace_persistence.py @@ -27,8 +27,8 @@ def test_atomic_round_trip_preserves_only_workspace_metadata(self): { "saved_at": "2026-08-09 15:00:00", "active_tasks": [ - {"slot_id": 3, "run_id": "run-3", "target_url": "https://example.test/a"}, - {"slot_id": 1, "run_id": "", "target_url": "https://example.test/b"}, + {"slot_id": 3, "workflow_id": "workflow_a", "run_id": "run-3", "target_url": "https://example.test/a"}, + {"slot_id": 1, "workflow_id": "workflow_b", "run_id": "", "target_url": "https://example.test/b"}, ], "next_slot_id": 7, "selected_page": "Tasks", @@ -99,18 +99,28 @@ def test_duplicate_or_invalid_task_slots_are_ignored_safely(self): ), encoding="utf-8", ) - loaded = WorkspaceStateStore(path).load() - self.assertEqual(loaded["active_tasks"], [{"slot_id": 2, "run_id": "a", "target_url": "u1"}]) + # v1.0.6.42 migrates legacy workspace rows only through an explicit + # workflow-identity resolver; this test remains focused on duplicate/invalid slots. + loaded = WorkspaceStateStore( + path, legacy_workflow_resolver=lambda slot_id, _run_id: "legacy_workflow" + ).load() + self.assertEqual( + loaded["active_tasks"], + [{"slot_id": 2, "workflow_id": "legacy_workflow", "run_id": "a", "target_url": "u1"}], + ) class WorkspacePersistenceScopeTest(unittest.TestCase): - def test_runtime_database_schema_remains_frozen(self): - self.assertEqual(SCHEMA_VERSION, 1) + def test_runtime_database_schema_phase2_successor_is_workflow_aware(self): + # v1.0.6.42 intentionally supersedes the v1 schema to persist immutable + # per-Task workflow provenance without changing unrelated runtime behavior. + self.assertEqual(SCHEMA_VERSION, 2) def test_qt_workspace_contract_contains_required_safe_restore_paths(self): text = (ROOT / "src" / "vibrapilot" / "qt_app.py").read_text(encoding="utf-8") for marker in ( - "WorkspaceStateStore(APP_STATE_FILE)", + "WorkspaceStateStore(", + "legacy_workflow_resolver=self._resolve_legacy_workspace_workflow_identity", "def schedule_workspace_save", "def save_workspace_state", "def _restore_active_workspace_tasks", diff --git a/tests/test_v10616_workspace_persistence_verification_fix.py b/tests/test_v10616_workspace_persistence_verification_fix.py index 6909093..a075ae3 100644 --- a/tests/test_v10616_workspace_persistence_verification_fix.py +++ b/tests/test_v10616_workspace_persistence_verification_fix.py @@ -15,6 +15,9 @@ def test_v10615_runtime_files_are_byte_frozen(self): v10630=ROOT/"config/verification/v1.0.6.30_workflow_plugin_system_scope.json" if v10630.is_file(): data=json.loads(v10630.read_text(encoding="utf-8")); allowed.update(data.get("allowed_production_source_changes",[])); allowed.update(data.get("authorized_nonproduction_files",[])) + v10642=ROOT/"config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json" + if v10642.is_file(): + data=json.loads(v10642.read_text(encoding="utf-8")); allowed.update(data.get("allowed_production_source_changes",[])); allowed.update(data.get("authorized_nonproduction_files",[])) for r,e in self.scope["runtime_byte_frozen_sha256"].items(): if r in allowed: continue self.assertEqual(hashlib.sha256((ROOT/r).read_bytes()).hexdigest(),e,r) diff --git a/tests/test_v10617_browser_capabilities.py b/tests/test_v10617_browser_capabilities.py index bc686bf..caa4f7b 100644 --- a/tests/test_v10617_browser_capabilities.py +++ b/tests/test_v10617_browser_capabilities.py @@ -59,7 +59,15 @@ def test_scope_identity_and_frozen_surfaces(self): pr08_scope = json.loads(PR08_SCOPE.read_text(encoding="utf-8")) pr08_allowed = set(pr08_scope["allowed_production_source_changes"]) v10630 = json.loads(V10630_SCOPE.read_text(encoding="utf-8")) if V10630_SCOPE.is_file() else {} - current_allowed = set(v10630.get("allowed_production_source_changes", [])) | set(v10630.get("authorized_nonproduction_files", [])) + v10642_authorized = { + "src/vibrapilot/backend.py", "src/vibrapilot/qt_app.py", + "src/vibrapilot/task_runtime_store.py", "src/vibrapilot/workspace_state.py", + "src/vibrapilot/workflow/__init__.py", "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workflow/state.py", + } + current_allowed = (set(v10630.get("allowed_production_source_changes", [])) + | set(v10630.get("authorized_nonproduction_files", [])) + | v10642_authorized) for relative, expected in scope["approved_target_file_sha256"].items(): if relative in ui_allowed or relative in foundation_allowed or relative in pr06_allowed or relative in pr08_allowed or relative in current_allowed: continue diff --git a/tests/test_v10623_pr06_workflow_state_atomic_switch.py b/tests/test_v10623_pr06_workflow_state_atomic_switch.py index 4cb0d94..bfdae39 100644 --- a/tests/test_v10623_pr06_workflow_state_atomic_switch.py +++ b/tests/test_v10623_pr06_workflow_state_atomic_switch.py @@ -298,12 +298,16 @@ def test_backend_worker_has_no_hardcoded_share_invite_fallback(): assert 'active_workflow_id="share_invite"' not in source -def test_qt_injects_authoritative_active_workflow_into_worker(): +def test_qt_injects_authoritative_task_workflow_into_worker(): source = (ROOT / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") assert 'APP_DATA_DIR / "workflow_state.json"' in source - assert "active_workflow_id=self.app.active_workflow_id" in source + # v1.0.6.42 intentionally supersedes the PR-06 global run identity: the + # persisted app workflow is now only the default for new Tasks. + assert "active_workflow_id=self.workflow_id" in source + assert "workflow_manager=self.app.workflow_catalog.for_active_workflow(self.workflow_id)" in source assert "WorkflowSwitchTransaction.recover_all(" in source assert "def request_workflow_switch(" in source + assert "def request_default_workflow_switch(" in source def test_same_workflow_is_noop_before_confirmation_or_mutation(): @@ -391,6 +395,16 @@ def test_v10636_registry_has_no_fake_or_builtin_production_workflow(): def test_frozen_out_of_scope_files_are_byte_identical_to_v10622_baseline(): pr08_authorized_supersession = {"src/vibrapilot/workflow_inputs.py"} + # v1.0.6.42 approved Phase-2 successor surface; all other PR-06 frozen hashes remain enforced. + v10642_authorized_supersession = { + "src/vibrapilot/backend.py", + "src/vibrapilot/qt_app.py", + "src/vibrapilot/task_runtime_store.py", + "src/vibrapilot/workspace_state.py", + "src/vibrapilot/workflow/__init__.py", + "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workflow/state.py", + } v10630_path = ROOT / "config/verification/v1.0.6.30_workflow_plugin_system_scope.json" v10630 = json.loads(v10630_path.read_text(encoding="utf-8")) if v10630_path.is_file() else {} v10631_path = ROOT / "config/verification/v1.0.6.31_chrome_only_browser_runtime_scope.json" @@ -399,6 +413,7 @@ def test_frozen_out_of_scope_files_are_byte_identical_to_v10622_baseline(): v10636 = json.loads(v10636_path.read_text(encoding="utf-8")) if v10636_path.is_file() else {} current_authorized = ( pr08_authorized_supersession + | v10642_authorized_supersession | set(v10630.get("allowed_production_source_changes", [])) | set(v10630.get("authorized_nonproduction_files", [])) | set(v10631.get("allowed_production_source_changes", [])) @@ -423,6 +438,9 @@ def test_safety_critical_worker_methods_remain_baseline_identical(): authorized_methods = ( set(current.get("authorized_automationworker_method_changes", [])) | set(v10631.get("authorized_automationworker_method_changes", [])) + # v1.0.6.42 adds only workflow provenance to report_row; other frozen + # AutomationWorker safety methods remain byte/AST protected here. + | {"report_row"} ) for name, expected in _scope()["frozen_automationworker_method_canonical_ast_sha256"].items(): if name in authorized_methods: diff --git a/tests/test_v10624_pr07_workflow_showcase.py b/tests/test_v10624_pr07_workflow_showcase.py index d9230b0..a1eee7c 100644 --- a/tests/test_v10624_pr07_workflow_showcase.py +++ b/tests/test_v10624_pr07_workflow_showcase.py @@ -149,18 +149,22 @@ def test_runtime_availability_uses_existing_fail_closed_factory_resolution(): assert "except WorkflowError:" in source -def test_valid_inactive_workflow_activation_delegates_only_to_pr06_service(): +def test_valid_inactive_workflow_activation_delegates_only_to_restart_free_phase2_service(): source = _source("_activate_workflow_from_showcase") - assert "self.request_workflow_switch(workflow_id)" in source - assert source.count("request_workflow_switch(") == 1 - for forbidden in ("WorkflowSwitchTransaction(", "commit_active_workflow(", "_confirm_workflow_switch(", "transaction.prepare("): + # v1.0.6.42 keeps the showcase thin but supersedes the restart-required PR-06 + # UI path with the approved default-for-new-Tasks service. + assert "self.request_default_workflow_switch(resolved)" in source + assert source.count("request_default_workflow_switch(") == 1 + assert "_confirm_default_workflow_switch(resolved)" in source + for forbidden in ("WorkflowSwitchTransaction(", "commit_active_workflow(", "_confirm_workflow_switch(", "transaction.prepare(", "_spawn_workflow_restart("): assert forbidden not in source -def test_activation_handler_preserves_pr06_status_ownership(): +def test_activation_handler_preserves_phase2_default_switch_status_ownership(): source = _source("_activate_workflow_from_showcase") - for status in ("already_active", "cancelled", "committed_restart_required", "switched"): + for status in ("already_active", "activated", "switched"): assert status in source + assert "committed_restart_required" not in source def test_logo_resolution_is_deterministic_and_has_no_discovery(): @@ -212,7 +216,17 @@ def test_frozen_runtime_config_dependency_and_ci_files_are_byte_identical(): v10631 = json.loads(V10631_SCOPE_PATH.read_text(encoding="utf-8")) if V10631_SCOPE_PATH.is_file() else {} v10636_path = ROOT / "config/verification/v1.0.6.36_share_invite_externalization_scope.json" v10636 = json.loads(v10636_path.read_text(encoding="utf-8")) if v10636_path.is_file() else {} - current_authorized = (pr08_authorized_supersession | pr10_authorized_supersession + # v1.0.6.42 approved Phase-2 successor files; dependency/CI and every other + # PR-07 frozen surface remain hash-enforced. + v10642_authorized_supersession = { + "src/vibrapilot/qt_app.py", + "src/vibrapilot/task_runtime_store.py", + "src/vibrapilot/workspace_state.py", + "src/vibrapilot/workflow/__init__.py", + "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workflow/state.py", + } + current_authorized = (pr08_authorized_supersession | pr10_authorized_supersession | v10642_authorized_supersession | set(v10630.get("allowed_production_source_changes", [])) | set(v10630.get("authorized_nonproduction_files", [])) | set(v10631.get("allowed_production_source_changes", [])) | set(v10631.get("authorized_nonproduction_files", [])) | set(v10636.get("allowed_production_source_changes", [])) | set(v10636.get("authorized_nonproduction_files", [])) diff --git a/tests/test_v10625_pr08_dynamic_workflow_inputs.py b/tests/test_v10625_pr08_dynamic_workflow_inputs.py index 9ebcbcb..17bbac0 100644 --- a/tests/test_v10625_pr08_dynamic_workflow_inputs.py +++ b/tests/test_v10625_pr08_dynamic_workflow_inputs.py @@ -134,9 +134,10 @@ def test_pr06_switch_boundary_keeps_legacy_clear_but_not_canonical_input_store() else: assert "Canonical per-workflow Workflow Input values will be preserved" in confirm -def test_new_workers_receive_snapshot_without_live_worker_mutation_path(): +def test_new_workers_receive_task_owned_snapshot_without_live_worker_mutation_path(): open_browser = _source(_task_method("open_browser")) - assert "workflow_input_values=self.app.current_workflow_input_snapshot()" in open_browser + # v1.0.6.42 resolves the immutable snapshot from the Task-owned workflow ID. + assert "workflow_input_values=self.app.current_workflow_input_snapshot(self.workflow_id)" in open_browser for source in ( _source(_main_method("save_workflow_inputs")), _source(_main_method("_persist_active_workflow_input_values")), @@ -168,7 +169,13 @@ def test_share_invite_runtime_and_pr06_workflow_engine_are_frozen(): v10630 = json.loads(V10630_SCOPE_PATH.read_text(encoding="utf-8")) if V10630_SCOPE_PATH.is_file() else {} v10631 = json.loads(V10631_SCOPE_PATH.read_text(encoding="utf-8")) if V10631_SCOPE_PATH.is_file() else {} v10636 = json.loads(V10636_SCOPE_PATH.read_text(encoding="utf-8")) if V10636_SCOPE_PATH.is_file() else {} - current_authorized = (pr10_authorized + v10642_authorized = { + "src/vibrapilot/backend.py", "src/vibrapilot/qt_app.py", + "src/vibrapilot/task_runtime_store.py", "src/vibrapilot/workspace_state.py", + "src/vibrapilot/workflow/__init__.py", "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workflow/state.py", + } + current_authorized = (pr10_authorized | v10642_authorized | set(v10630.get("allowed_production_source_changes", [])) | set(v10630.get("authorized_nonproduction_files", [])) | set(v10631.get("allowed_production_source_changes", [])) | set(v10631.get("authorized_nonproduction_files", [])) | set(v10636.get("allowed_production_source_changes", [])) | set(v10636.get("authorized_nonproduction_files", [])) diff --git a/tests/test_v10626_pr09_data_persistence_reporting_compatibility.py b/tests/test_v10626_pr09_data_persistence_reporting_compatibility.py index ee11c27..5293a10 100644 --- a/tests/test_v10626_pr09_data_persistence_reporting_compatibility.py +++ b/tests/test_v10626_pr09_data_persistence_reporting_compatibility.py @@ -53,12 +53,12 @@ def test_scope_locks_no_runtime_change_and_schema_v1(): assert SCOPE["pr10_not_started"] is True -def test_task_runtime_sqlite_schema_remains_exact_v1_without_workflow_identity(tmp_path): +def test_task_runtime_sqlite_schema_phase2_successor_adds_only_workflow_provenance(tmp_path): db = tmp_path / "task_runtime.sqlite3" TaskRuntimeStore(db) - assert SCHEMA_VERSION == 1 + assert SCHEMA_VERSION == 2 assert _table_columns(db, "runs") == [ - "run_id", "schema_version", "slot_id", "target_url", "source_file", + "run_id", "schema_version", "slot_id", "workflow_id", "target_url", "source_file", "source_fingerprint", "current_index", "total", "success_count", "failed_count", "send_limit_used", "task_status", "manual_review_required", "created_at", "updated_at", "completed_at", @@ -67,21 +67,21 @@ def test_task_runtime_sqlite_schema_remains_exact_v1_without_workflow_identity(t "run_id", "item_index", "email", "name", "status", "attempts", "message", "result", ] assert _table_columns(db, "results") == [ - "run_id", "item_index", "timestamp", "slot_id", "email", "status", + "run_id", "item_index", "timestamp", "slot_id", "workflow_id", "email", "status", "message", "attempts", "target_url", "result", ] with sqlite3.connect(db) as conn: tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} assert tables == {"runs", "items", "results"} - assert all("workflow_id" not in _table_columns(db, table) for table in tables) -def test_taskitem_and_report_contracts_remain_backward_compatible(): +def test_taskitem_contract_remains_backward_compatible_and_report_adds_workflow_provenance(): assert [f.name for f in fields(TaskItem)] == SCOPE["taskitem_fields"] + # Historical PR-09 report columns remain documented, while v1.0.6.42 adds + # workflow_id as the single provenance column required by true multiworkflow. expected = SCOPE["live_report_columns"] - assert 'columns = ["timestamp", "slot_id", "email", "status", "message", "attempts", "target_url", "result"]' in QT_SOURCE assert expected == ["timestamp", "slot_id", "email", "status", "message", "attempts", "target_url", "result"] - assert "workflow_id" not in expected and "workflow_name" not in expected + assert 'columns = ["timestamp", "slot_id", "workflow_id", "email", "status", "message", "attempts", "target_url", "result"]' in QT_SOURCE def test_import_contract_preserves_txt_csv_xlsx_and_xls(tmp_path): @@ -133,21 +133,21 @@ def test_report_exports_keep_current_columns_and_formula_safety(tmp_path): assert exported_xlsx.loc[0, "message"] == "'=unsafe" -def test_workspace_schema_remains_lightweight_and_drops_unapproved_workflow_namespace(tmp_path): - assert WORKSPACE_STATE_SCHEMA_VERSION == 1 +def test_workspace_schema_remains_lightweight_with_task_only_workflow_namespace(tmp_path): + assert WORKSPACE_STATE_SCHEMA_VERSION == 2 path = tmp_path / "state.json" store = WorkspaceStateStore(path) store.save({ - "saved_at": "now", "active_tasks": [{"slot_id": 2, "run_id": "r", "target_url": "u", "workflow_id": "fake"}], + "saved_at": "now", "active_tasks": [{"slot_id": 2, "run_id": "r", "target_url": "u", "workflow_id": "workflow_a"}], "next_slot_id": 3, "selected_page": "Reports", "window": {"x": 1, "y": 2, "width": 800, "height": 600, "maximized": False}, - "workflow_id": "fake", + "workflow_id": "top-level-must-not-persist", }) loaded = store.load() assert loaded is not None assert set(loaded) == {"schema_version", "saved_at", "active_tasks", "next_slot_id", "selected_page", "window"} assert "workflow_id" not in loaded - assert "workflow_id" not in loaded["active_tasks"][0] + assert loaded["active_tasks"][0]["workflow_id"] == "workflow_a" def test_switch_clear_and_preserve_boundary_is_exact_and_no_wrong_workflow_recovery_path(): diff --git a/tests/test_v10627_pr10_workflow_error_recovery.py b/tests/test_v10627_pr10_workflow_error_recovery.py index d4a7438..b5e9d3d 100644 --- a/tests/test_v10627_pr10_workflow_error_recovery.py +++ b/tests/test_v10627_pr10_workflow_error_recovery.py @@ -98,6 +98,12 @@ def test_approved_frozen_runtime_surfaces_remain_byte_identical(): current_authorized.update(scope.get("allowed_production_source_changes", [])) current_authorized.update(scope.get("authorized_nonproduction_files", [])) current_authorized.update(scope.get("deleted_production_paths", [])) + current_authorized.update({ + "src/vibrapilot/backend.py", "src/vibrapilot/qt_app.py", + "src/vibrapilot/task_runtime_store.py", "src/vibrapilot/workspace_state.py", + "src/vibrapilot/workflow/__init__.py", "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workflow/state.py", + }) for rel, expected in FROZEN.items(): if rel in current_authorized: continue @@ -107,7 +113,8 @@ def test_approved_frozen_runtime_surfaces_remain_byte_identical(): def test_active_runtime_factory_is_preflighted_before_worker_creation(): can_open = _method_source("can_open_task_browser") open_browser = (ROOT / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") - assert "runtime_error = self._refresh_workflow_runtime_error()" in can_open + # v1.0.6.42 preflights the immutable Task workflow rather than the app default. + assert "runtime_error = self._workflow_runtime_error_for(slot.workflow_id)" in can_open assert "Browser creation is blocked before worker startup" in can_open task_method = ast.get_source_segment( open_browser, @@ -124,7 +131,7 @@ def test_runtime_error_blocks_browser_but_is_not_a_switch_away_blocker(): can_open = _method_source("can_open_task_browser") switch_block = _method_source("_workflow_switch_block_reason") switch = _method_source("request_workflow_switch") - assert "self.workflow_runtime_error" in can_open or "_refresh_workflow_runtime_error" in can_open + assert "_workflow_runtime_error_for(slot.workflow_id)" in can_open assert "workflow_runtime_error" not in switch_block assert "require_runtime_factory(target)" in switch diff --git a/tests/test_v10628_pr11_windows_multitask_regression.py b/tests/test_v10628_pr11_windows_multitask_regression.py index dd32139..53b8c71 100644 --- a/tests/test_v10628_pr11_windows_multitask_regression.py +++ b/tests/test_v10628_pr11_windows_multitask_regression.py @@ -186,6 +186,12 @@ def test_no_pr11_production_runtime_source_change_is_permitted(self): | set(v10636_scope.get("allowed_production_source_changes", [])) | set(v10637_scope.get("allowed_production_source_changes", [])) | set(v10639_scope.get("allowed_production_source_changes", [])) + | { + "src/vibrapilot/backend.py", "src/vibrapilot/qt_app.py", + "src/vibrapilot/task_runtime_store.py", "src/vibrapilot/workspace_state.py", + "src/vibrapilot/workflow/__init__.py", "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workflow/state.py", + } ) deleted = set(v10636_scope.get("deleted_production_paths", [])) historical = set(hashes) diff --git a/tests/test_v10630_workflow_plugin_regression.py b/tests/test_v10630_workflow_plugin_regression.py index b66cd9b..df00f95 100644 --- a/tests/test_v10630_workflow_plugin_regression.py +++ b/tests/test_v10630_workflow_plugin_regression.py @@ -10,12 +10,13 @@ QT = (ROOT / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") -def test_v10636_has_zero_builtins_and_retains_one_active_workflow_model(): +def test_v10636_has_zero_builtins_and_phase2_retains_one_default_with_task_owned_execution(): manager = WorkflowManager.with_builtin_workflows() assert manager.list_workflows() == () assert manager.active_workflow_id is None - assert 'active_workflow_id=self.app.active_workflow_id' in QT - assert 'def request_workflow_switch(' in QT + assert 'active_workflow_id=self.workflow_id' in QT + assert 'def request_workflow_switch(' in QT # historical compatibility path retained + assert 'def request_default_workflow_switch(' in QT def test_core_browser_is_retained_and_specialized_workflow_processing_is_generic_hook(): diff --git a/tests/test_v10630_workflow_task_ui.py b/tests/test_v10630_workflow_task_ui.py index a534dce..9a8e22a 100644 --- a/tests/test_v10630_workflow_task_ui.py +++ b/tests/test_v10630_workflow_task_ui.py @@ -15,9 +15,10 @@ def test_task_card_uses_workflow_schema_and_settings_dialog_while_preserving_cor def test_worker_receives_immutable_workflow_snapshots_and_ui_consumes_step_metric_events(): - assert 'workflow_settings_values=self.app.current_workflow_settings_snapshot()' in QT + assert 'workflow_input_values=self.app.current_workflow_input_snapshot(self.workflow_id)' in QT + assert 'workflow_settings_values=self.app.current_workflow_settings_snapshot(self.workflow_id)' in QT assert 'workflow_task_values=dict(self.workflow_task_values)' in QT - assert 'workflow_manager=self.app.workflow_catalog.for_active_workflow' in QT + assert 'workflow_manager=self.app.workflow_catalog.for_active_workflow(self.workflow_id)' in QT assert 'elif kind == "workflow_step" and slot:' in QT assert 'elif kind == "workflow_metric" and slot:' in QT @@ -30,4 +31,6 @@ def test_task_slot_preserves_lightweight_qt_host_constructor_compatibility(): assert 'or "share_invite"' not in QT assert 'getattr(app, "workflow_catalog", None)' in QT assert 'getattr(app, "workflow_task_state_store", None)' in QT - assert 'setObjectName("TaskSubtitle")' not in QT + # v1.0.6.42 intentionally adds a read-only workflow identity subtitle while + # preserving the optional constructor/fallback compatibility above. + assert 'setObjectName("TaskSubtitle")' in QT diff --git a/tests/test_v10634_ui_compact_polish.py b/tests/test_v10634_ui_compact_polish.py index fbadbb2..cda6033 100644 --- a/tests/test_v10634_ui_compact_polish.py +++ b/tests/test_v10634_ui_compact_polish.py @@ -1,11 +1,13 @@ from __future__ import annotations import ast +import json from pathlib import Path ROOT = Path(__file__).resolve().parents[1] QT_PATH = ROOT / "src" / "vibrapilot" / "qt_app.py" WIDGETS_PATH = ROOT / "vib_validation_app" / "widgets.py" +V10642_SCOPE = ROOT / "config" / "verification" / "v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json" STYLES_PATH = ROOT / "vib_validation_app" / "styles.py" QT_TEXT = QT_PATH.read_text(encoding="utf-8") WIDGETS_TEXT = WIDGETS_PATH.read_text(encoding="utf-8") @@ -62,8 +64,16 @@ def test_task_card_keeps_workflow_identity_in_state_but_not_as_visible_subtitle( task_cls = next(n for n in QT_TREE.body if isinstance(n, ast.ClassDef) and n.name == "TaskSlotWidget") build = next(n for n in task_cls.body if isinstance(n, ast.FunctionDef) and n.name == "_build") source = ast.get_source_segment(QT_TEXT, build) or "" - assert 'f"Workflow: {workflow_name}"' not in source - assert 'setObjectName("TaskSubtitle")' not in source + phase2_qt = False + if V10642_SCOPE.is_file(): + phase2 = json.loads(V10642_SCOPE.read_text(encoding="utf-8")) + phase2_qt = "src/vibrapilot/qt_app.py" in set(phase2.get("allowed_production_source_changes", [])) + if phase2_qt: + assert 'f"Workflow: {workflow_name} • v{workflow_version}"' in source + assert 'setObjectName("TaskSubtitle")' in source + else: + assert 'f"Workflow: {workflow_name}"' not in source + assert 'setObjectName("TaskSubtitle")' not in source task_source = ast.get_source_segment(QT_TEXT, task_cls) or "" assert "self.workflow_id" in task_source @@ -150,8 +160,13 @@ def test_activation_keeps_functional_feedback_but_removes_decorative_subtitle_an def test_ui_polish_does_not_touch_frozen_behavior_markers(): # Static safety anchors: the UI polish must not remove existing functional calls. + switch_marker = "self.request_workflow_switch(workflow_id)" + if V10642_SCOPE.is_file(): + phase2 = json.loads(V10642_SCOPE.read_text(encoding="utf-8")) + if "src/vibrapilot/qt_app.py" in set(phase2.get("allowed_production_source_changes", [])): + switch_marker = "self.request_default_workflow_switch(resolved)" for marker in ( - "self.request_workflow_switch(workflow_id)", + switch_marker, "self.start_chrome_install", "self.save_workflow_inputs", "self.save_workflow_settings", diff --git a/tests/test_v10637_portable_release_packaging.py b/tests/test_v10637_portable_release_packaging.py index 46b44f0..149b6ba 100644 --- a/tests/test_v10637_portable_release_packaging.py +++ b/tests/test_v10637_portable_release_packaging.py @@ -189,7 +189,12 @@ def test_ci_stability_fix_keeps_production_store_frozen_and_widens_only_test_gua assert correction["application_business_logic_changed"] is False runtime_store = ROOT / "src/vibrapilot/task_runtime_store.py" - assert hashlib.sha256(runtime_store.read_bytes()).hexdigest() == scope["frozen_task_runtime_store_sha256"] + v10642_scope = ROOT / "config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json" + v10642_allowed = set() + if v10642_scope.is_file(): + v10642_allowed.update(json.loads(v10642_scope.read_text(encoding="utf-8")).get("allowed_production_source_changes", [])) + if "src/vibrapilot/task_runtime_store.py" not in v10642_allowed: + assert hashlib.sha256(runtime_store.read_bytes()).hexdigest() == scope["frozen_task_runtime_store_sha256"] ci = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") portable = (ROOT / ".github/workflows/portable-release.yml").read_text(encoding="utf-8") diff --git a/tests/test_v10638_portable_runtime_root_fix.py b/tests/test_v10638_portable_runtime_root_fix.py index a8fa184..ef9f3af 100644 --- a/tests/test_v10638_portable_runtime_root_fix.py +++ b/tests/test_v10638_portable_runtime_root_fix.py @@ -15,6 +15,7 @@ SCOPE = ROOT / "config/verification/v1.0.6.38_portable_runtime_root_fix_scope.json" V10639_SCOPE = ROOT / "config/verification/v1.0.6.39_runtime_reliability_session_policy_scope.json" +V10642_SCOPE = ROOT / "config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json" def _sha256(path: Path) -> str: @@ -89,6 +90,10 @@ def test_v10638_freezes_runtime_and_build_surfaces_outside_root_helper(): scope = json.loads(SCOPE.read_text(encoding="utf-8")) later = json.loads(V10639_SCOPE.read_text(encoding="utf-8")) if V10639_SCOPE.is_file() else {} later_allowed = set(later.get("allowed_production_source_changes", [])) | set(later.get("allowed_runtime_config_changes", [])) + if V10642_SCOPE.is_file(): + phase2 = json.loads(V10642_SCOPE.read_text(encoding="utf-8")) + later_allowed.update(phase2.get("allowed_production_source_changes", [])) + later_allowed.update(phase2.get("allowed_runtime_config_changes", [])) for relative, expected in scope["frozen_file_sha256"].items(): if relative in later_allowed: continue diff --git a/tests/test_v10642_chrome_final_acceptance_contract.py b/tests/test_v10642_chrome_final_acceptance_contract.py new file mode 100644 index 0000000..58ab56c --- /dev/null +++ b/tests/test_v10642_chrome_final_acceptance_contract.py @@ -0,0 +1,24 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_existing_chrome_secure_install_contract_remains_enforced(): + installer = (ROOT / "src/vibrapilot/chrome_installer.py").read_text(encoding="utf-8") + runtime = (ROOT / "src/vibrapilot/chrome_runtime.py").read_text(encoding="utf-8") + qt = (ROOT / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") + backend = (ROOT / "src/vibrapilot/backend.py").read_text(encoding="utf-8") + + for marker in ( + '"https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi"', + 'GOOGLE_CHROME_EXPECTED_PUBLISHER = "Google LLC"', + "inspect_windows_authenticode", + 'info.lpVerb = "runas"', + "post_install_not_found", + ): + assert marker in installer + assert 'channel"] = "chrome"' in backend + assert 'channel"] = "chromium"' not in backend + assert "discover_google_chrome" in runtime + assert "def check_chrome_prerequisite_on_startup" in qt + assert "self.app.ensure_chrome_ready(interactive=True)" in qt diff --git a/tests/test_v10642_multiworkflow_identity.py b/tests/test_v10642_multiworkflow_identity.py new file mode 100644 index 0000000..75ee5af --- /dev/null +++ b/tests/test_v10642_multiworkflow_identity.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import ast +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +QT_PATH = ROOT / "src" / "vibrapilot" / "qt_app.py" + + +def _method_source(name: str) -> str: + text = QT_PATH.read_text(encoding="utf-8") + tree = ast.parse(text) + cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "MainWindow") + node = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == name) + return ast.get_source_segment(text, node) or "" + + +def _task_method_source(name: str) -> str: + text = QT_PATH.read_text(encoding="utf-8") + tree = ast.parse(text) + cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "TaskSlotWidget") + node = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == name) + return ast.get_source_segment(text, node) or "" + + +def test_task_slot_accepts_immutable_explicit_workflow_identity_with_legacy_default_compatibility(): + source = QT_PATH.read_text(encoding="utf-8") + assert 'def __init__(self, app: "MainWindow", slot_id: int, workflow_id: str | None = None)' in source + assert 'self.workflow_id = str(workflow_id or getattr(app, "active_workflow_id", "") or "compatibility_host")' in source + + +def test_worker_creation_uses_task_owned_workflow_not_app_global_workflow(): + source = _task_method_source("open_browser") + assert "active_workflow_id=self.workflow_id" in source + assert "for_active_workflow(self.workflow_id)" in source + assert "current_workflow_input_snapshot(self.workflow_id)" in source + assert "current_workflow_settings_snapshot(self.workflow_id)" in source + assert "active_workflow_id=self.app.active_workflow_id" not in source + + +def test_restart_free_default_workflow_activation_does_not_clear_tasks_or_spawn_process(): + source = _method_source("request_default_workflow_switch") + assert "commit_default_workflow" in source + assert "_reload_workflow_catalog" in source + assert "_spawn_workflow_restart" not in source + assert "_clear_workflow_scoped_state" not in source + assert "self.tasks.clear" not in source + + +def test_add_task_can_bind_selected_workflow_and_task_cards_show_workflow_identity(): + source = QT_PATH.read_text(encoding="utf-8") + assert "def _select_workflow_for_new_task" in source + assert "def _add_task_with_id(self, slot_id: int, workflow_id: str | None = None)" in source + assert "Workflow:" in source + assert "workflow_version" in source + + +def test_workflows_page_exposes_update_remove_and_deactivate_without_new_top_level_page(): + source = QT_PATH.read_text(encoding="utf-8") + for marker in ( + 'button("Update", "secondary")', + 'button("Remove", "danger")', + 'button("Deactivate", "secondary")', + "def update_workflow_plugin", + "def remove_workflow_plugin", + "def deactivate_default_workflow", + ): + assert marker in source + assert 'NAV_SECTIONS = ["Dashboard", "Tasks", "Workflows", "Workflow Inputs", "Workflow Settings", "Reports", "Live Logs", "App Settings", "Browser Settings", "About"]' in source + + + +def test_lifecycle_blocker_ignores_completed_closed_history_but_blocks_unfinished_closed_task(): + source = _method_source("_workflow_reference_block_reason") + assert "self.runtime_store.recoverable_runs()" in source + assert "self.runtime_store.closed_runs()" in source + assert 'if row.get("completed_at") is None' in source + assert "recoverable/unfinished closed Task runtime(s)" in source + + +def test_two_workers_keep_independent_workflow_identity_and_report_provenance(tmp_path): + import queue + import threading + from _v10630_plugin_fixture import write_plugin_package + from vibrapilot.backend import AutomationWorker, DEFAULT_SETTINGS, TaskItem, TaskState + from vibrapilot.workflow import WorkflowManager, inspect_workflow_package, install_workflow_package + + root = tmp_path / "Workflows" + for workflow_id in ("workflow_a", "workflow_b"): + package = write_plugin_package(tmp_path / f"{workflow_id}.vpworkflow", workflow_id) + install_workflow_package(inspect_workflow_package(package), root, reserved_workflow_ids=set()) + catalog = WorkflowManager.with_available_workflows(workflow_root=root) + + workers = [] + for slot_id, workflow_id in ((1, "workflow_a"), (2, "workflow_b")): + worker = AutomationWorker( + TaskState(slot_id=slot_id), + dict(DEFAULT_SETTINGS), + queue.Queue(), + threading.Event(), + threading.Event(), + "https://example.test", + active_workflow_id=workflow_id, + workflow_manager=catalog.for_active_workflow(workflow_id), + ) + workers.append(worker) + + assert workers[0]._workflow_manager.active_workflow_id == "workflow_a" + assert workers[1]._workflow_manager.active_workflow_id == "workflow_b" + assert workers[0].report_row(TaskItem("a@example.com"), "")["workflow_id"] == "workflow_a" + assert workers[1].report_row(TaskItem("b@example.com"), "")["workflow_id"] == "workflow_b" + + +def test_default_workflow_switch_requires_explicit_confirmation_and_never_restarts_tasks(): + activate = _method_source("_activate_workflow_from_showcase") + confirm = _method_source("_confirm_default_workflow_switch") + assert "_confirm_default_workflow_switch" in activate + assert "Existing Tasks keep their current workflow identities" in confirm + assert "_spawn_workflow_restart" not in activate + confirm + assert "_clear_workflow_scoped_state" not in activate + confirm + + +def test_active_unavailable_workflow_still_exposes_deactivate_escape_path(): + source = _method_source("_workflow_card") + assert "if is_active:" in source + active_block = source[source.index("if is_active:"):source.index("elif recovery_available:")] + assert 'button("Deactivate", "secondary")' in active_block + assert 'badge.setText("UNAVAILABLE")' in active_block + assert 'button("Unavailable", "secondary")' not in active_block + + +def test_initial_default_task_slots_do_not_open_repeated_workflow_selector_dialogs(): + text = QT_PATH.read_text(encoding="utf-8") + marker = "# Preserve baseline first-workspace behavior" + assert marker in text + segment = text[text.index(marker) - 250:text.index(marker) + 800] + assert "self._add_task_with_id(candidate, self.active_workflow_id)" in segment + assert "self.add_task()" not in segment diff --git a/tests/test_v10642_multiworkflow_persistence.py b/tests/test_v10642_multiworkflow_persistence.py new file mode 100644 index 0000000..05313db --- /dev/null +++ b/tests/test_v10642_multiworkflow_persistence.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + + +def test_workspace_v2_persists_workflow_id_and_migrates_v1_with_resolver(tmp_path: Path): + import json + from vibrapilot.workspace_state import WorkspaceStateStore, WORKSPACE_STATE_SCHEMA_VERSION + + assert WORKSPACE_STATE_SCHEMA_VERSION == 2 + path = tmp_path / "state.json" + path.write_text(json.dumps({ + "schema_version": 1, + "saved_at": "now", + "active_tasks": [{"slot_id": 3, "run_id": "run3", "target_url": "https://x"}], + "next_slot_id": 4, + "selected_page": "Tasks", + "window": {"x": 1, "y": 2, "width": 800, "height": 600, "maximized": False}, + }), encoding="utf-8") + store = WorkspaceStateStore(path, legacy_workflow_resolver=lambda slot, run: "workflow_a" if slot == 3 else None) + state = store.load() + assert state is not None + assert state["schema_version"] == 2 + assert state["active_tasks"][0]["workflow_id"] == "workflow_a" + assert json.loads(path.read_text(encoding="utf-8"))["schema_version"] == 2 + + +def test_workspace_v1_unresolved_task_is_not_assigned_an_arbitrary_workflow(tmp_path: Path): + import json + from vibrapilot.workspace_state import WorkspaceStateStore + + path = tmp_path / "state.json" + path.write_text(json.dumps({ + "schema_version": 1, + "saved_at": "now", + "active_tasks": [{"slot_id": 1, "run_id": "legacy", "target_url": ""}], + "next_slot_id": 2, + "selected_page": "Tasks", + "window": {}, + }), encoding="utf-8") + store = WorkspaceStateStore(path, legacy_workflow_resolver=lambda _slot, _run: None) + state = store.load() + assert state is not None + assert state["active_tasks"] == [] + assert "workflow identity" in store.warning.lower() + assert store.migration_blocked is True + # Fail closed without rewriting away the unresolved legacy Task shell. + assert json.loads(path.read_text(encoding="utf-8"))["schema_version"] == 1 + + +def test_task_runtime_store_v2_persists_workflow_provenance_and_filters_results(tmp_path: Path): + from vibrapilot.task_runtime_store import TaskRuntimeStore, SCHEMA_VERSION + + assert SCHEMA_VERSION == 2 + store = TaskRuntimeStore(tmp_path / "store.sqlite3") + item = SimpleNamespace(email="a@example.com", name="A", status="pending", attempts=0, message="", result="") + run_id = store.start_run( + slot_id=1, + workflow_id="workflow_a", + target_url="https://example.test", + source_file="x.txt", + source_fingerprint="abc", + items=[item], + created_at="2026-08-18 00:00:00", + ) + store.upsert_result(run_id, 0, { + "timestamp": "2026-08-18 00:00:01", "slot_id": 1, "workflow_id": "workflow_a", + "email": "a@example.com", "status": "success", "message": "", "attempts": 1, + "target_url": "https://example.test", "result": "ok", + }) + run = store.load_run(run_id) + assert run and run["workflow_id"] == "workflow_a" + assert store.results(workflow_id="workflow_a")[0]["workflow_id"] == "workflow_a" + assert store.results(workflow_id="workflow_b") == [] + assert store.result_workflow_ids() == ["workflow_a"] + + +def test_qt_workspace_snapshot_restore_reports_and_dashboard_are_workflow_aware(): + text = (Path(__file__).resolve().parents[1] / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") + assert '"workflow_id": task.workflow_id' in text + assert '_add_task_with_id(slot_id, workflow_id)' in text + assert 'self.report_workflow = combo_box(["All Workflows"])' in text + assert '"workflow_id"' in text[text.index("def make_reports_page"):text.index("def make_logs_page")] + assert "result_workflow_ids" in text + assert "workflow_groups" in text + + +def test_task_runtime_store_migrates_v1_database_without_inventing_workflow_identity(tmp_path: Path): + import sqlite3 + from vibrapilot.task_runtime_store import TaskRuntimeStore, SCHEMA_VERSION + + path = tmp_path / "legacy.sqlite3" + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, schema_version INTEGER NOT NULL, slot_id INTEGER NOT NULL, + target_url TEXT NOT NULL DEFAULT '', source_file TEXT NOT NULL DEFAULT '', + source_fingerprint TEXT NOT NULL DEFAULT '', current_index INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, send_limit_used INTEGER NOT NULL DEFAULT 0, + task_status TEXT NOT NULL DEFAULT 'Ready', manual_review_required INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, completed_at TEXT + ); + CREATE TABLE items ( + run_id TEXT NOT NULL, item_index INTEGER NOT NULL, email TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', result TEXT NOT NULL DEFAULT '', PRIMARY KEY(run_id,item_index) + ); + CREATE TABLE results ( + run_id TEXT NOT NULL, item_index INTEGER NOT NULL, timestamp TEXT NOT NULL, + slot_id INTEGER NOT NULL, email TEXT NOT NULL, status TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', attempts INTEGER NOT NULL DEFAULT 0, + target_url TEXT NOT NULL DEFAULT '', result TEXT NOT NULL DEFAULT '', PRIMARY KEY(run_id,item_index) + ); + """ + ) + conn.execute( + "INSERT INTO runs(run_id,schema_version,slot_id,target_url,source_file,source_fingerprint,current_index,total,success_count,failed_count,send_limit_used,task_status,manual_review_required,created_at,updated_at,completed_at) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ("legacy-run", 1, 3, "https://legacy", "", "", 1, 1, 1, 0, 0, "Completed", 0, "t0", "t1", "t1"), + ) + conn.execute( + "INSERT INTO items(run_id,item_index,email,name,status,attempts,message,result) VALUES(?,?,?,?,?,?,?,?)", + ("legacy-run", 0, "old@example.com", "Old", "success", 1, "", "ok"), + ) + conn.execute( + "INSERT INTO results(run_id,item_index,timestamp,slot_id,email,status,message,attempts,target_url,result) VALUES(?,?,?,?,?,?,?,?,?,?)", + ("legacy-run", 0, "t1", 3, "old@example.com", "success", "", 1, "https://legacy", "ok"), + ) + conn.commit() + conn.close() + + store = TaskRuntimeStore(path) + run = store.load_run("legacy-run") + assert run is not None + assert run["schema_version"] == SCHEMA_VERSION == 2 + assert run["workflow_id"] == "" + results = store.results() + assert results[0]["workflow_id"] == "" + assert results[0]["email"] == "old@example.com" + + with sqlite3.connect(path) as check: + assert "workflow_id" in {row[1] for row in check.execute("PRAGMA table_info(runs)")} + assert "workflow_id" in {row[1] for row in check.execute("PRAGMA table_info(results)")} + + +def test_report_export_preserves_workflow_provenance_column(tmp_path: Path): + import csv + from vibrapilot.data_io import export_report_csv + + path = tmp_path / "multiworkflow.csv" + export_report_csv( + [{ + "timestamp": "t", "slot_id": 1, "workflow_id": "workflow_a", + "email": "a@example.com", "status": "success", "message": "", "attempts": 1, + "target_url": "https://example.test", "result": "ok", + }], + path, + ) + with path.open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + assert reader.fieldnames is not None + assert "workflow_id" in reader.fieldnames + assert next(reader)["workflow_id"] == "workflow_a" + + +def test_mainwindow_does_not_overwrite_workspace_while_legacy_workflow_identity_is_unresolved(): + text = (Path(__file__).resolve().parents[1] / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") + assert text.count("self.workspace_store.migration_blocked") >= 2 + recovery_tail = text[text.index("QTimer.singleShot(0, self.offer_task_recovery)") - 220:text.index("QTimer.singleShot(0, self.offer_task_recovery)") + 80] + assert "self.active_workflow_id" not in recovery_tail + + +def test_legacy_report_rows_are_labeled_unknown_without_inventing_database_identity(): + text = (Path(__file__).resolve().parents[1] / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") + assert 'display["workflow_id"] = "Legacy / Unknown"' in text + assert "self._report_display_row(row)" in text diff --git a/tests/test_v10642_workflow_lifecycle.py b/tests/test_v10642_workflow_lifecycle.py new file mode 100644 index 0000000..00a96b0 --- /dev/null +++ b/tests/test_v10642_workflow_lifecycle.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from _v10630_plugin_fixture import plugin_payload, write_plugin_package + + +def _versioned_package(path: Path, workflow_id: str, version: str) -> Path: + manifest = dict(plugin_payload(workflow_id)["manifest.json"]) + manifest["version"] = version + return write_plugin_package(path, workflow_id, mutate={"manifest.json": manifest}) + + +def test_numeric_workflow_version_comparison_is_strict_and_not_lexicographic(): + from vibrapilot.workflow import compare_workflow_versions + + assert compare_workflow_versions("1.0.9", "1.0.10") < 0 + assert compare_workflow_versions("1.0.10", "1.0.9") > 0 + assert compare_workflow_versions("1.2", "1.2.0") == 0 + assert compare_workflow_versions("2", "1.99.99") > 0 + + +def test_update_workflow_package_requires_strictly_newer_version_and_replaces_atomically(tmp_path: Path): + from vibrapilot.workflow import ( + inspect_workflow_package, + install_workflow_package, + update_workflow_package, + WorkflowManager, + ) + + root = tmp_path / "Workflows" + v1 = _versioned_package(tmp_path / "v1.vpworkflow", "invoice_fixture", "1.0.0") + install_workflow_package(inspect_workflow_package(v1), root, reserved_workflow_ids=set()) + + same = _versioned_package(tmp_path / "same.vpworkflow", "invoice_fixture", "1.0.0") + with pytest.raises(Exception, match="strictly newer"): + update_workflow_package(inspect_workflow_package(same), root, reserved_workflow_ids=set()) + + lower = _versioned_package(tmp_path / "lower.vpworkflow", "invoice_fixture", "0.9.9") + with pytest.raises(Exception, match="strictly newer"): + update_workflow_package(inspect_workflow_package(lower), root, reserved_workflow_ids=set()) + + v2 = _versioned_package(tmp_path / "v2.vpworkflow", "invoice_fixture", "1.1.0") + updated = update_workflow_package(inspect_workflow_package(v2), root, reserved_workflow_ids=set()) + assert updated.manifest.version == "1.1.0" + assert WorkflowManager.with_available_workflows(workflow_root=root).require_workflow("invoice_fixture").version == "1.1.0" + metadata = json.loads((root / "invoice_fixture" / ".vibrapilot-plugin.json").read_text(encoding="utf-8")) + assert metadata["package_sha256"] == inspect_workflow_package(v2).package_sha256 + assert not list(root.glob(".lifecycle-*")) + + +def test_update_rolls_back_old_workflow_if_post_swap_validation_fails(monkeypatch, tmp_path: Path): + from vibrapilot.workflow import inspect_workflow_package, install_workflow_package + from vibrapilot.workflow import plugin_loader + + root = tmp_path / "Workflows" + old_package = _versioned_package(tmp_path / "old.vpworkflow", "invoice_fixture", "1.0.0") + install_workflow_package(inspect_workflow_package(old_package), root, reserved_workflow_ids=set()) + old_manifest_bytes = (root / "invoice_fixture" / "manifest.json").read_bytes() + + new_package = _versioned_package(tmp_path / "new.vpworkflow", "invoice_fixture", "1.1.0") + inspection = inspect_workflow_package(new_package) + real_load = plugin_loader.load_workflow_directory + calls = {"destination": 0} + + def fail_only_after_swap(path: Path): + path = Path(path) + if path.resolve() == (root / "invoice_fixture").resolve(): + calls["destination"] += 1 + if calls["destination"] == 1: + raise plugin_loader.WorkflowPluginValidationError("synthetic post-swap validation failure") + return real_load(path) + + monkeypatch.setattr(plugin_loader, "load_workflow_directory", fail_only_after_swap) + with pytest.raises(Exception, match="post-swap"): + plugin_loader.update_workflow_package(inspection, root, reserved_workflow_ids=set()) + + assert (root / "invoice_fixture" / "manifest.json").read_bytes() == old_manifest_bytes + assert real_load(root / "invoice_fixture").manifest.version == "1.0.0" + + +def test_remove_installed_workflow_removes_only_executable_package_directory(tmp_path: Path): + from vibrapilot.workflow import inspect_workflow_package, install_workflow_package, remove_installed_workflow + + root = tmp_path / "Workflows" + package = _versioned_package(tmp_path / "one.vpworkflow", "invoice_fixture", "1.0.0") + install_workflow_package(inspect_workflow_package(package), root, reserved_workflow_ids=set()) + unrelated = tmp_path / "workflow_inputs.json" + unrelated.write_text('{"preserve": true}', encoding="utf-8") + + removed = remove_installed_workflow("invoice_fixture", root) + assert removed.workflow_id == "invoice_fixture" + assert not (root / "invoice_fixture").exists() + assert unrelated.read_text(encoding="utf-8") == '{"preserve": true}' + + +def test_prepared_update_transaction_recovers_previous_workflow_after_interrupted_swap(tmp_path: Path): + import os + import shutil + + from vibrapilot.workflow import ( + inspect_workflow_package, + install_workflow_package, + load_workflow_directory, + recover_workflow_lifecycle_transactions, + ) + + root = tmp_path / "Workflows" + old_package = _versioned_package(tmp_path / "old-recovery.vpworkflow", "invoice_fixture", "1.0.0") + new_package = _versioned_package(tmp_path / "new-recovery.vpworkflow", "invoice_fixture", "1.1.0") + install_workflow_package(inspect_workflow_package(old_package), root, reserved_workflow_ids=set()) + + tx = root / ".transactions" / "invoice_fixture-update-synthetic" + tx.mkdir(parents=True) + backup = tx / "backup" + os.replace(root / "invoice_fixture", backup) + + # Model an interrupted update after a replacement directory became visible but + # before COMMITTED was persisted. Recovery must prefer the backed-up old package. + staged_root = tmp_path / "new-visible" + staged_root.mkdir() + new_inspection = inspect_workflow_package(new_package) + with __import__("zipfile").ZipFile(new_package) as archive: + archive.extractall(staged_root) + # The test fixture package is rooted directly at archive root; add install metadata + # by copying from a normally installed temporary instance so directory validation is exact. + temp_install_root = tmp_path / "temp-install" + install_workflow_package(new_inspection, temp_install_root, reserved_workflow_ids=set()) + shutil.rmtree(staged_root) + os.replace(temp_install_root / "invoice_fixture", root / "invoice_fixture") + + (tx / "transaction.json").write_text( + json.dumps( + { + "schema_version": 1, + "status": "PREPARED", + "action": "update", + "workflow_id": "invoice_fixture", + "target_version": "1.1.0", + "created_at": "2026-08-18T00:00:00+00:00", + } + ), + encoding="utf-8", + ) + + actions = recover_workflow_lifecycle_transactions(root) + assert actions == ["rolled back update transaction for invoice_fixture"] + assert load_workflow_directory(root / "invoice_fixture").manifest.version == "1.0.0" + assert not (root / ".transactions").exists() + + +def test_default_workflow_can_be_deactivated_without_mutating_task_state(tmp_path: Path): + from vibrapilot.workflow import WorkflowManager, WorkflowStateStore, inspect_workflow_package, install_workflow_package + + root = tmp_path / "Workflows" + package = _versioned_package(tmp_path / "default-state.vpworkflow", "invoice_fixture", "1.0.0") + install_workflow_package(inspect_workflow_package(package), root, reserved_workflow_ids=set()) + manager = WorkflowManager.with_available_workflows(workflow_root=root) + store = WorkflowStateStore( + tmp_path / "workflow_state.json", + manager=manager, + default_workflow_id="invoice_fixture", + ) + original = store.load_or_migrate() + assert original.active_workflow_id == "invoice_fixture" + + deactivated = store.commit_default_workflow( + None, expected_current_workflow_id="invoice_fixture" + ) + assert deactivated.active_workflow_id is None + assert deactivated.revision == original.revision + 1 + assert store.load_existing().active_workflow_id is None + + +def test_workflow_update_ui_rejects_same_or_lower_version_before_confirmation(): + import ast + qt = (Path(__file__).resolve().parents[1] / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") + tree = ast.parse(qt) + cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "MainWindow") + method = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "_update_workflow_from_inspection") + source = ast.get_source_segment(qt, method) or "" + assert "compare_workflow_versions(manifest.version, current.version) <= 0" in source + assert source.index("compare_workflow_versions") < source.index('"Trust and update workflow"') + + +def test_load_is_fail_closed_when_lifecycle_recovery_is_unresolved(): + import ast + qt = (Path(__file__).resolve().parents[1] / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") + tree = ast.parse(qt) + cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "MainWindow") + method = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "load_workflow_plugin") + source = ast.get_source_segment(qt, method) or "" + assert "self.workflow_lifecycle_error" in source + assert 'self.workflow_plugin_root / ".transactions"' in source + assert source.index("workflow_lifecycle_error") < source.index("QFileDialog.getOpenFileName") + + +def test_unresolved_lifecycle_transaction_blocks_browser_automation_and_new_tasks(): + import ast + qt = (Path(__file__).resolve().parents[1] / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") + tree = ast.parse(qt) + cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "MainWindow") + methods = {n.name: ast.get_source_segment(qt, n) or "" for n in cls.body if isinstance(n, ast.FunctionDef)} + assert "self.workflow_lifecycle_error" in methods["can_open_task_browser"] + assert "self.workflow_lifecycle_error" in methods["add_task"] + assert "Workflow lifecycle recovery" in qt + + +def test_package_mutation_api_fails_closed_while_lifecycle_transaction_is_pending(tmp_path: Path): + from vibrapilot.workflow import inspect_workflow_package, install_workflow_package + + root = tmp_path / "Workflows" + pending = root / ".transactions" / "stale" + pending.mkdir(parents=True) + package = _versioned_package(tmp_path / "blocked.vpworkflow", "invoice_fixture", "1.0.0") + with pytest.raises(Exception, match="pending workflow lifecycle transaction"): + install_workflow_package(inspect_workflow_package(package), root, reserved_workflow_ids=set()) + + +def test_remove_rejects_path_like_workflow_ids_before_filesystem_mutation(tmp_path: Path): + from vibrapilot.workflow import remove_installed_workflow + + root = tmp_path / "Workflows" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + for unsafe in ("../outside", r"..\\outside", "C:outside", ".", ".."): + with pytest.raises(Exception, match="safe installed workflow identifier"): + remove_installed_workflow(unsafe, root) + assert outside.is_dir() + + +def test_lifecycle_recovery_fails_closed_on_unexpected_transaction_root_file(tmp_path: Path): + from vibrapilot.workflow import recover_workflow_lifecycle_transactions + + root = tmp_path / "Workflows" + tx_root = root / ".transactions" + tx_root.mkdir(parents=True) + (tx_root / "unexpected.txt").write_text("unexpected", encoding="utf-8") + with pytest.raises(Exception, match="unexpected entries"): + recover_workflow_lifecycle_transactions(root) diff --git a/vibproject.ygit b/vibproject.ygit index 767b115..e74eb5d 100644 --- a/vibproject.ygit +++ b/vibproject.ygit @@ -8,8 +8,8 @@ "slug": "vibrapilot", "displayName": "VibraPilot", "description": "VibraPilot browser automation desktop application by Vib Tools.", - "summary": "VibraPilot v1.0.6.41 seals the Phase 1 deterministic active-page ownership contract by canonicalizing omitted and explicit default HTTP/HTTPS ports as the same browser origin; all other Phase 1 runtime behavior and Phase 2 remain unchanged.", - "version": "1.0.6.41", + "summary": "VibraPilot v1.0.6.42 completes restart-free workflow lifecycle management and true per-Task multiworkflow with workflow-aware persistence, recovery, reporting and dashboard isolation while preserving Plugin API 1 and the existing Chrome secure-install architecture.", + "version": "1.0.6.42", "status": "production", "visibility": "private", "category": "desktop-application", From ab148f8137c1066e497136d6246ac6f84db54024 Mon Sep 17 00:00:00 2001 From: vib-tools Date: Tue, 18 Aug 2026 11:43:00 -0700 Subject: [PATCH 2/2] fix(workflow): close Phase 2 lifecycle safety gaps v1.0.6.43 --- CHANGELOG.md | 13 + CITATION.cff | 2 +- COMPATIBILITY.md | 11 + DELTA_FILE_LIST.txt | 61 ++--- PATCH_MANIFEST.md | 74 +++--- PROJECT_STRUCTURE.md | 15 ++ README.md | 6 + ROADMAP.md | 12 + SHA256SUMS.txt | 111 ++++---- UPDATE_LOG.md | 10 + VERSIONING.md | 10 + config/AppConfig/app.py | 2 +- ....0.6.43_phase2_forensic_closure_scope.json | 98 ++++++++ docs/docs.manifest.ygit | 6 +- docs/index.md | 10 + .../v1.0.6.43-phase2-forensic-closure.md | 22 ++ .../V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md | 31 +++ pyproject.toml | 2 +- scripts/verify_repository.py | 118 ++++++++- src/vibrapilot/qt_app.py | 238 ++++++------------ src/vibrapilot/workflow/plugin_loader.py | 50 +++- src/vibrapilot/workspace_state.py | 9 +- tests/test_app_config.py | 2 +- ...10623_pr06_workflow_state_atomic_switch.py | 35 +-- tests/test_v10624_pr07_workflow_showcase.py | 2 +- ...ata_persistence_reporting_compatibility.py | 21 +- ...est_v10627_pr10_workflow_error_recovery.py | 2 +- tests/test_v10642_workflow_lifecycle.py | 15 +- tests/test_v10643_phase2_forensic_closure.py | 123 +++++++++ vibproject.ygit | 4 +- 30 files changed, 748 insertions(+), 367 deletions(-) create mode 100644 config/verification/v1.0.6.43_phase2_forensic_closure_scope.json create mode 100644 docs/updates/v1.0.6.43-phase2-forensic-closure.md create mode 100644 docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md create mode 100644 tests/test_v10643_phase2_forensic_closure.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7020196..21b038b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## v1.0.6.43 — Phase 2 Forensic Closure — 2026-08-18 + +- Re-audited v1.0.6.42 workflow lifecycle and true multiworkflow implementation against the approved Phase-2 contract. +- Made the historical `request_workflow_switch()` compatibility service restart-free instead of retaining the old destructive switch/restart path. +- Hardened lifecycle transaction recovery and mutation blocking against malformed transaction roots and unsafe transaction workflow identities. +- Preserved schema-v2 workspace Task shells when workflow identity is missing or temporarily unavailable, blocking autosave instead of silently dropping persisted Tasks. +- Blocked workflow package mutation when unfinished legacy runtime identity cannot be resolved and blocked new Task/browser starts while a live lifecycle transaction exists. +- Corrected visible workflow semantics from global `ACTIVE` language to `DEFAULT` for the workflow used by newly created Tasks. +- Removed one duplicated staging-directory setup statement in the workflow package loader. +- Preserved v1.0.6.42 multiworkflow architecture, Plugin API 1, Chrome secure-install/runtime, browser profiles, power management, licensing, settings defaults, dependencies, CI and portable packaging. + +--- + ## v1.0.6.42 — Phase 2 Workflow Lifecycle Management + True Multiworkflow — 2026-08-18 - Added strict-newer workflow package Update/Replace with atomic staging, rollback and lifecycle transaction recovery. diff --git a/CITATION.cff b/CITATION.cff index d1b798d..f899bea 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: 1.2.0 title: "VibraPilot — Vib Tools Browser Automation Desktop" message: "If you reference this software, cite the Vib Tools project." type: software -version: 1.0.6.42 +version: 1.0.6.43 authors: - name: "Vib Tools" url: "https://vib.tools/" diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index ef06772..d140f76 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,3 +1,14 @@ +# v1.0.6.43 Phase 2 Forensic Closure Compatibility + +- External Workflow Plugin API remains version 1. +- Existing v1.0.6.42 workflow packages, per-Task identities, runtime/workspace schema v2 and Chrome-only behavior are preserved. +- Normal/default workflow switching remains restart-free, including the historical compatibility entry point. +- Corrupt/pending lifecycle state now fails closed rather than allowing package mutation or new browser execution. +- Unresolved or unavailable workspace workflow identity preserves persisted Task shells instead of silently rewriting them away. +- No top-level UI page, dependency, Chrome installer/runtime, licensing, power, CI or packaging architecture change is introduced. + +--- + # v1.0.6.42 Phase 2 Workflow Lifecycle / Multiworkflow Compatibility - Baseline compatibility: v1.0.6.41; external Plugin API 1 is unchanged. diff --git a/DELTA_FILE_LIST.txt b/DELTA_FILE_LIST.txt index 34b9db4..b8a1e4f 100644 --- a/DELTA_FILE_LIST.txt +++ b/DELTA_FILE_LIST.txt @@ -10,59 +10,36 @@ SHA256SUMS.txt UPDATE_LOG.md VERSIONING.md config/AppConfig/app.py -config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json +config/verification/v1.0.6.43_phase2_forensic_closure_scope.json docs/docs.manifest.ygit docs/index.md -docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md -docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md +docs/updates/v1.0.6.43-phase2-forensic-closure.md +docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md +project/CURRENT_PROJECT_STATE.md +project/README.md +project/updates/v1.0.6.39-v1.0.6.40-production-readiness/03_PHASE_PROGRESS_COMPLETION_LEDGER.md +project/updates/v1.0.6.39-v1.0.6.40-production-readiness/04_ERROR_HANDLING_AUDIT_AND_PLAN.md +project/updates/v1.0.6.39-v1.0.6.40-production-readiness/05_ACTUAL_IMPLEMENTATION_STATUS.md +project/updates/v1.0.6.39-v1.0.6.40-production-readiness/07_ACCEPTANCE_TEST_MATRIX.md +project/updates/v1.0.6.39-v1.0.6.40-production-readiness/08_REQUIREMENTS_TRACEABILITY_MATRIX.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/08_PHASE2_PROGRESS_LEDGER.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/09_ACTUAL_IMPLEMENTATION_STATUS.md +project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/10_FINAL_VERIFICATION_REPORT.md +project/updates/v1.0.6.43-phase2-forensic-closure/00_SCOPE_LOCK.md +project/updates/v1.0.6.43-phase2-forensic-closure/01_FORENSIC_AUDIT_REPORT.md +project/updates/v1.0.6.43-phase2-forensic-closure/02_ROOT_CAUSE_AND_FIX_REPORT.md +project/updates/v1.0.6.43-phase2-forensic-closure/03_VERIFICATION_MATRIX.md +project/updates/v1.0.6.43-phase2-forensic-closure/04_ACTUAL_IMPLEMENTATION_STATUS.md pyproject.toml scripts/verify_repository.py -src/vibrapilot/backend.py src/vibrapilot/qt_app.py -src/vibrapilot/task_runtime_store.py -src/vibrapilot/workflow/__init__.py src/vibrapilot/workflow/plugin_loader.py -src/vibrapilot/workflow/state.py src/vibrapilot/workspace_state.py tests/test_app_config.py -tests/test_pr03_workflow_contracts_registry.py -tests/test_v10614_managed_persistent_browser.py -tests/test_v10615_workspace_persistence.py -tests/test_v10616_workspace_persistence_verification_fix.py -tests/test_v10617_browser_capabilities.py tests/test_v10623_pr06_workflow_state_atomic_switch.py tests/test_v10624_pr07_workflow_showcase.py -tests/test_v10625_pr08_dynamic_workflow_inputs.py tests/test_v10626_pr09_data_persistence_reporting_compatibility.py tests/test_v10627_pr10_workflow_error_recovery.py -tests/test_v10628_pr11_windows_multitask_regression.py -tests/test_v10630_workflow_plugin_regression.py -tests/test_v10630_workflow_task_ui.py -tests/test_v10634_ui_compact_polish.py -tests/test_v10637_portable_release_packaging.py -tests/test_v10638_portable_runtime_root_fix.py -tests/test_v10642_chrome_final_acceptance_contract.py -tests/test_v10642_multiworkflow_identity.py -tests/test_v10642_multiworkflow_persistence.py tests/test_v10642_workflow_lifecycle.py +tests/test_v10643_phase2_forensic_closure.py vibproject.ygit -project/CURRENT_PROJECT_STATE.md -project/README.md -project/updates/v1.0.6.39-v1.0.6.40-production-readiness/02_TWO_PHASE_ROADMAP.md -project/updates/v1.0.6.39-v1.0.6.40-production-readiness/03_PHASE_PROGRESS_COMPLETION_LEDGER.md -project/updates/v1.0.6.39-v1.0.6.40-production-readiness/04_ERROR_HANDLING_AUDIT_AND_PLAN.md -project/updates/v1.0.6.39-v1.0.6.40-production-readiness/05_ACTUAL_IMPLEMENTATION_STATUS.md -project/updates/v1.0.6.39-v1.0.6.40-production-readiness/07_ACCEPTANCE_TEST_MATRIX.md -project/updates/v1.0.6.39-v1.0.6.40-production-readiness/08_REQUIREMENTS_TRACEABILITY_MATRIX.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/00_SCOPE_LOCK.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/01_BASELINE_FORENSIC_REPORT.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/02_IMPLEMENTATION_PLAN.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/03_WORKFLOW_LIFECYCLE_TRANSACTION_PLAN.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/04_MULTIWORKFLOW_ARCHITECTURE.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/05_STATE_SCHEMA_MIGRATION_PLAN.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/06_ERROR_HANDLING_MATRIX.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/07_CHROME_FINAL_ACCEPTANCE_MATRIX.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/08_PHASE2_PROGRESS_LEDGER.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/09_ACTUAL_IMPLEMENTATION_STATUS.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/10_FINAL_VERIFICATION_REPORT.md -project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/11_WINDOWS_ACCEPTANCE_RECORD.md diff --git a/PATCH_MANIFEST.md b/PATCH_MANIFEST.md index e797686..5b2ea17 100644 --- a/PATCH_MANIFEST.md +++ b/PATCH_MANIFEST.md @@ -1,42 +1,54 @@ -# VibraPilot v1.0.6.42 — Phase 2 Replace-Ready Patch +# VibraPilot v1.0.6.43 — Phase 2 Forensic Closure Replace-Ready Patch -## Baseline identity +## Official baseline -- Official input: `VibraPilot_Official_v1.0.6.41_Baseline(1).zip` -- Input SHA-256: `9296626e20076a5ded1a2c6b854ce25489b09be9d8fb204061eba14612642982` -- Baseline version: `1.0.6.41` -- Baseline Git commit: `615fe1148431b90334e9ff3f9ae02b37a36bd1d8` -- Baseline Git tree: `a6cb42814d7ed993ff5961823cf681e0cb0c0252` -- Target version: `1.0.6.42` +- Input: `VibraPilot_Official_v1.0.6.42_Baseline(1).zip` +- Input SHA-256: `0713798e061b0eef15c1a1d4cb52e1347687f7498419b9ab1a79b3017f31dae1` +- Baseline version: `1.0.6.42` +- Baseline branch commit: `8c3e9a137ecf852aa152dba0590414e7f8f6209d` +- Baseline Git tree: `c61a9864e99659bf0a3d3cb229e1ad6e5e85cb69` +- Target version: `1.0.6.43` -## Scope +## Corrective scope -- strict-newer Workflow Update/Replace with staged validation, rollback and crash recovery; -- Workflow Remove/Unload and default Deactivate; -- restart-free normal activation/switch/lifecycle; -- immutable per-Task workflow identity and simultaneous different-workflow Tasks; -- workspace/runtime schema-v2 workflow provenance; -- workflow-aware Reports and per-workflow Dashboard metrics; -- existing Chrome prerequisite/secure-install source re-verified byte-frozen. +- restart-free historical workflow-switch compatibility service; +- lifecycle transaction root/type and workflow-identity fail-closed validation; +- schema-v2 workspace Task-shell preservation when workflow identity is unresolved/unavailable; +- unresolved legacy unfinished-run package-mutation blocking; +- same-session live lifecycle transaction blocking for package mutation, Task creation and browser start; +- Default Workflow UI semantics replacing residual global `ACTIVE` wording; +- removal of one duplicated staging-directory setup statement. -## Frozen boundaries +## Production source changes -Plugin API 1, Chrome prerequisite implementation, browser profile architecture, power management, licensing, runtime settings defaults, dependencies, CI and portable packaging are unchanged. +- `src/vibrapilot/qt_app.py` +- `src/vibrapilot/workflow/plugin_loader.py` +- `src/vibrapilot/workspace_state.py` -## Automated verification +Chrome prerequisite/runtime/installer/AuthentiCode, Plugin API 1, backend worker logic, runtime DB schema-v2 implementation, power, licensing, settings defaults, dependencies, CI and portable packaging are frozen from v1.0.6.42. -- repository verifier: **PASS** -- full pytest: **541 passed, 6 skipped, 105 subtests passed** -- full unittest: **201 OK, 6 skipped** -- compileall: **PASS** -- `git diff --check`: **PASS** -- deleted files: **0** +## Verification evidence -## Replace-ready inventory +- v1.0.6.42 baseline: repository verifier PASS; pytest 541 passed / 6 skipped / 105 subtests; unittest 201 OK / 6 skipped; compileall PASS. +- tests-first v1.0.6.43 reproduction: 8 concrete failures reproduced / 1 guard already passing. +- corrected v1.0.6.43 closure tests: 9 PASS. +- targeted historical/current correction gate: 123 PASS. +- broader Phase-1/Phase-2/persistence/Chrome gate: 190 PASS. +- frozen SHA audit: PASS. +- metadata/current scope tests: 13 PASS; repository verifier PASS. +- final full pytest: 550 passed, 6 skipped, 105 subtests passed. +- final full unittest: 201 OK, 6 skipped. +- compileall: PASS. +- `git diff --check`: PASS. +- deleted files: 0. -- Public changed/new files: **48** -- Private/local `project/` files: **20** -- Total Delta entries: **68** -- `project/**` is private/local only and must never be staged or pushed. +## Delta inventory -Windows live acceptance and GitHub v1.0.6.42 CI remain pending external evidence. +- Public changed/new files: 30 +- Private/local `project/` files: 15 +- Total Delta entries: 45 +- `project/**` is local/private only and must never be staged/pushed. + +## External gates + +Windows live acceptance and GitHub v1.0.6.43 CI remain PENDING and are not claimed PASS. diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 890c051..30bf882 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -1,3 +1,18 @@ +# Project Structure — v1.0.6.43 Phase 2 Forensic Closure + +```text +src/vibrapilot/qt_app.py + Restart-free compatibility switch; live lifecycle blocking; legacy-run reference safety; Default Workflow UI wording. +src/vibrapilot/workflow/plugin_loader.py + Lifecycle-root/type/identity fail-closed validation; duplicate staging setup removed. +src/vibrapilot/workspace_state.py + Schema-v2 unresolved workflow Task-shell preservation and autosave blocking. +``` + +All other v1.0.6.42 production architecture remains frozen. + +--- + # Project Structure — v1.0.6.42 Phase 2 ```text diff --git a/README.md b/README.md index 51ae20a..8fdda09 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +# VibraPilot v1.0.6.43 — Phase 2 Forensic Closure + +v1.0.6.43 is a scope-locked corrective seal over v1.0.6.42. It preserves Workflow Update/Remove/Deactivate, restart-free lifecycle, immutable per-Task workflow identity and true simultaneous multiworkflow execution while closing concrete lifecycle-transaction, legacy-recovery, workspace-preservation and Default Workflow UI semantic gaps. Chrome prerequisite/secure-install, Plugin API 1, licensing, browser-profile, power, dependency, CI and packaging architecture remain unchanged. + +--- + # VibraPilot v1.0.6.42 — Workflow Lifecycle + True Multiworkflow v1.0.6.42 completes the approved Phase 2 production update: trusted workflow packages can be updated, removed or deactivated safely without normal app restart; the persisted default workflow applies only to newly created Tasks; each Task owns an immutable workflow identity; and different workflows can execute concurrently under the existing Task/profile isolation rules. Workspace/runtime persistence, Reports and Dashboard now retain workflow provenance. The existing Google Chrome prerequisite and secure installer implementation remains unchanged and fail-closed. diff --git a/ROADMAP.md b/ROADMAP.md index 72fa7ce..d634421 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,3 +1,15 @@ +# Current Roadmap — v1.0.6.43 Phase 2 Forensic Closure + +## Phase 2 status + +**LOCAL CODE + AUTOMATED FORENSIC VERIFICATION COMPLETE / WINDOWS LIVE ACCEPTANCE AND GITHUB v1.0.6.43 CI PENDING.** v1.0.6.43 is limited to concrete defects reproduced while auditing the owner-frozen v1.0.6.42 Phase-2 implementation. No new feature phase is introduced. + +## Closure scope + +Restart-free compatibility switching, lifecycle transaction fail-closed safety, workspace Task-shell preservation, unresolved legacy-run mutation blocking, live transaction execution blocking and Default Workflow UI semantics. All Chrome/Plugin API/licensing/power/dependency/CI/packaging surfaces remain frozen. + +--- + # Current Roadmap — v1.0.6.42 Phase 2 ## Phase 2 — Workflow Lifecycle Management & True Multiworkflow diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt index e88cb45..15147ad 100644 --- a/SHA256SUMS.txt +++ b/SHA256SUMS.txt @@ -1,67 +1,44 @@ -75c64d5dc254e53ecbea004d64e31acae060b50eccbc4244d4f79d216de1060d CHANGELOG.md -5e8b43841c41ccec04098bd9ab7dbf992d000e905bbdff46274f2b4b9c49753b CITATION.cff -3d2418dc8dc73c6bd373d03b3b300e05694302a79100be21edf8c9ccc578f87b COMPATIBILITY.md -f1390afa16726c533012221596f6e3b920c5ea80d798d970324a513855a22679 DELTA_FILE_LIST.txt -efb2921213e26637cfe2c60eda9cff995517d49f5c834246a49391807880383e PATCH_MANIFEST.md -e10336f406462d659c8d27cb01d676b6310edc95989f445a277cd4c5d5617c8c PROJECT_STRUCTURE.md -7cb3d18959992071ae121c43d5f77b268b803129d331bea625c3d821b699c2f4 README.md -38a85064f1b34a6234d7746089558d04f9b6ce929e38e07c126144b7e4704d88 ROADMAP.md -cb445e8dd23c49f38789cabe7d8444d1c320c50712373dd714c10da5513aa8eb UPDATE_LOG.md -7f7ff4366e7757fd6d78ba3a036f37deca2a7587f7f384817f2ab0bb77334883 VERSIONING.md -85e8bdf9f1e6a33599ec79632578bff1f1999401aebbade9f29402b488d37d94 config/AppConfig/app.py -d509f79f603ae60d548ddb73da1fc218147e913ab1c9cce5e1b780568b2dcbc5 config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json -fae806875ddf9b2465e6bc054ce9bbd3f2befbd18d5f8a8384db970fbb9412fc docs/docs.manifest.ygit -0b57282606ad3b9fe3d7e939c97073716391d294d569cf2fda532d24c7a3d862 docs/index.md -cc352e49c89089b7af9cd287543e61a87920aec19328c726403954ce5ed84fd2 docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md -b7e5dcd2a01c59ff6fb346ffbd2cbb079331355a9b3f2fe30cce62d793ca24be docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md -6ef90acd1a967737c2824bc6241eb70fed2a094894b1e03cc380e9e6694f6034 pyproject.toml -9089e5a5d93b8f63d31d3c1692059b6720892a5638b6f42d75b63bf8e9414d08 scripts/verify_repository.py -a3badec5f9473aea559ead1f891fa561a6f90c764f76900d87c380c3de7733af src/vibrapilot/backend.py -6725d668ce31d99f73f202e70607820f87961e28741e576b8733a330de43507c src/vibrapilot/qt_app.py -a5fd334d54d2a512f75f5c2f491727cf475673154a4583c70cada366b486e03a src/vibrapilot/task_runtime_store.py -a3242663312fef3fe44156f1b9ae3d5821bdb5d2de3d629e5814183615ce1f5a src/vibrapilot/workflow/__init__.py -5e75aa4ffdc870d5936031036259cb29eb0ef56de7706adc2364b5c3ea4eaff9 src/vibrapilot/workflow/plugin_loader.py -e89f94635020db073f00a12c7dc536f2e8a564fc36aae1b1d1d9f5e8dd71099f src/vibrapilot/workflow/state.py -049c3811ea790cf4c043bbbbd13962630c88dfc11e35eb6cb9be99eedd8c19be src/vibrapilot/workspace_state.py -ee5acf375120c6096e1badfe744c839f35008ac5295f5a3dcdb39962a63e62a8 tests/test_app_config.py -4f147e74964ff0a34d52773a7ed0b3f23b8287f619274c871521babb438233b7 tests/test_pr03_workflow_contracts_registry.py -98f0fd053b4302f7fc8acdcb0daa79d22196101f23f1984f2acc64d06ccca2d5 tests/test_v10614_managed_persistent_browser.py -bf6adb7f04334eaf18aa3341c4a9235e2619a766bb850f6fc9df348adbb5ab63 tests/test_v10615_workspace_persistence.py -d3e3d16dabddb9b106b3fb3c05a4524385d7e2457a67d2bcdfa244389d4f9162 tests/test_v10616_workspace_persistence_verification_fix.py -4c67a25fe79bd08b1fed4fec4e4d933745d024013d55f0918988e66d244d3505 tests/test_v10617_browser_capabilities.py -0cd04a4b7cb732c3e806f47a86380c554f418e742d8a9853ce6ab8ad841972d3 tests/test_v10623_pr06_workflow_state_atomic_switch.py -92a5a3e489016c40c530c220f472760b3633714326144886ebaf3b82cfec146c tests/test_v10624_pr07_workflow_showcase.py -eee85363157f2f8a7c6f52ab9c0794b28c54af35f4074cdb8ab0e45d67f08ac4 tests/test_v10625_pr08_dynamic_workflow_inputs.py -61708510305a602c25864e2825b1203b7afbeb74af3009407f323e79039d5001 tests/test_v10626_pr09_data_persistence_reporting_compatibility.py -e4bf3d6c984c25f19f8e7ea965f4c477e830d80e82f4635700fb62e303a35816 tests/test_v10627_pr10_workflow_error_recovery.py -b683274bbd81b3fe8d2eb90f289af2e7ce2823c02f9ddcf5faf66bfb7d7c3b8c tests/test_v10628_pr11_windows_multitask_regression.py -050e48547db600fc829a273f309c6782338af1d584962d43efb0924abff4f737 tests/test_v10630_workflow_plugin_regression.py -77a6d2bd38f8e3e2dfa7e18e3bea644aabc45dca23cc2ce08e8d32f68689789a tests/test_v10630_workflow_task_ui.py -0d21e3eeeda2094acc1cab03675ab97bf5c6ef45f618f5566445a427770df788 tests/test_v10634_ui_compact_polish.py -c3636e7bb21d91e1a702ffe20eb2975a3b143f0b8cf6de0541e40786cc7ebaa3 tests/test_v10637_portable_release_packaging.py -c7ed7b50177aa1f59b44a0380e6b80853044755fed0e0392aa1e5eae11d7dc5a tests/test_v10638_portable_runtime_root_fix.py -e711976349a3ea31b36d9fcc2a247b4517e277f53bd3be73ae29871dccf5f44c tests/test_v10642_chrome_final_acceptance_contract.py -7cafac1d828e5c24a172b2160e10f0e081430b9accde3bb7bcd701b13a726f23 tests/test_v10642_multiworkflow_identity.py -bba85a890c0b212bc151dce50de5ae63e4445c93db6b1e41cf3505e9eb78f7a5 tests/test_v10642_multiworkflow_persistence.py -de779902c7d025a4ab4d15c6df03961faf65bb1baa66f92126ca8fe9dcbdba64 tests/test_v10642_workflow_lifecycle.py -dd060b860f125f54b7bd31bea5c535acdecf53aaf659c4d4d36923a61f6d0d20 vibproject.ygit -b134951150468917983dbf1ce5bec993b18043807deccf02fbb4df8a6d1e5c26 project/CURRENT_PROJECT_STATE.md -87d7f2cee4f0001da74e240717c342f9778fd4389c32dbec24a234314b556932 project/README.md -9c6c71513b686a3723642061124237fa54b5716669933fbb80125c608b7742bc project/updates/v1.0.6.39-v1.0.6.40-production-readiness/02_TWO_PHASE_ROADMAP.md -ea46e3f37b32aaae15aa6a9376691512f75db7c1ae87f4311154808fcf140990 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/03_PHASE_PROGRESS_COMPLETION_LEDGER.md -8a79d2d3a61f699b16f59e61fe1276ab0008f98e97688175c8fa5ee046fb7f5e project/updates/v1.0.6.39-v1.0.6.40-production-readiness/04_ERROR_HANDLING_AUDIT_AND_PLAN.md -6b5fcb40b70f75904a013a063705dec07d0e499c9d854d41b2ac88f67653d03e project/updates/v1.0.6.39-v1.0.6.40-production-readiness/05_ACTUAL_IMPLEMENTATION_STATUS.md -ae53198d430c6c1fbe67743c1cd4e5b6b7e6eba50c70f1557b3293c755c76d52 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/07_ACCEPTANCE_TEST_MATRIX.md -d1caefabf76b0e049f36b0eab2b05f33610d65c98be219cb6f5a533134561e99 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/08_REQUIREMENTS_TRACEABILITY_MATRIX.md -b1608dd019747fce5f60452a70cbb9817043249a7b92643b9f64ad9aacb3afde project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/00_SCOPE_LOCK.md -a4050f7147cdfa0f4cc78c9071aca7c75415b0e0c59719221e983e16cc2c78fc project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/01_BASELINE_FORENSIC_REPORT.md -87ee3a4119b592dd7a65bd6b22d4d7d0d6485fffa773b96a1b7329fba0744dae project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/02_IMPLEMENTATION_PLAN.md -f7d409ecaa2b04d7f8eeda8d70dff035426901fbf2917ace739399613907e133 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/03_WORKFLOW_LIFECYCLE_TRANSACTION_PLAN.md -87b42f53d61c52434d08108fe305a8c9e7e2d650c4aa2858a15ac3df71c81abd project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/04_MULTIWORKFLOW_ARCHITECTURE.md -f17ff666820f5177c9cc14f393b14f263544165e60f0be50d7fba956e7bf1db6 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/05_STATE_SCHEMA_MIGRATION_PLAN.md -ec11459210e79e120fb2196dc574991dd1fcc43a1181015e1337a7d258227c09 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/06_ERROR_HANDLING_MATRIX.md -c7e719c435c412daddd7bcc4c2c783f2dc111f4fb5d1caebfe7971c925c3c36c project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/07_CHROME_FINAL_ACCEPTANCE_MATRIX.md -e29a08c771f019c0c12b8a7e0bd2a1bce52e7659223775effbb64a8ac043c834 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/08_PHASE2_PROGRESS_LEDGER.md -f6a3934711ed905e4d3909159d9bbee3c367ef6a46d4ee9f3d1e0f8346751eea project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/09_ACTUAL_IMPLEMENTATION_STATUS.md -208cda8bed0fc7a6bc54d307806e98854178eb5b9fed46ea15d490e480885950 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/10_FINAL_VERIFICATION_REPORT.md -9778eb9db98e1604d42037bbdb46f48922d67d1e473f0a36538bc4b8c69bcfdd project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/11_WINDOWS_ACCEPTANCE_RECORD.md +3ca9efd2042d59e05ddf81905cc811c37bce42b23af042a6dc9e14fb38d642c3 CHANGELOG.md +55382d5dafbab1aba4eb0996690d609634536bd8939b23d4aa71e735fb848f42 CITATION.cff +e235132874f0227512d450b8381810b4fa06449050d5adc0d340feee55b46850 COMPATIBILITY.md +f091ee13ba2934dd04d045a2442085c44778ce6d44a003367300652190b9127f DELTA_FILE_LIST.txt +3cb32c4eaa173cbff68b07b04e6a5539d2fe3e6b172b0df28df79e707c1a6d02 PATCH_MANIFEST.md +71397439692a9ba350b1d8e644da373823bb0f314c40491c948885de94e4d812 PROJECT_STRUCTURE.md +fbb8f9040f3b54ed6ae4258dace8ef19e88272f7eff2f0c56c4943eff67356b6 README.md +8e74f72ac687d8382b3ccbe3ebea0b632151f036e3620a58226ddf2d13b37e52 ROADMAP.md +e1d2d87987b77eb6f918b0869752e8e6a33c646f03fb404b685e58117fd827c9 UPDATE_LOG.md +0661aa6b4fc0f4089832dab9d02dd835c236609eca468b38af7d03b057c779e0 VERSIONING.md +4d1e2490170d7757fadf29c08decd2c838ebd1fbcb04c63a7d55732759de6701 config/AppConfig/app.py +279bf322a267bd90c319af0d1a946ecc98f30f1237fb519a15cc2a8fee228b04 config/verification/v1.0.6.43_phase2_forensic_closure_scope.json +126d4fe3c24215edd2a86a8c725e5226d88508f7d760d1fb85c542e0d33d4022 docs/docs.manifest.ygit +6546113c1da8907713592217ecb200abedc9400eea0ed5059f250a12a526e51b docs/index.md +562264a0c3849e3a107d180dc69b6310cdfac8a808397dfd8e72d3f665882324 docs/updates/v1.0.6.43-phase2-forensic-closure.md +83d348427d10e73fb14f2d164a7c0e96572089161e9cbc6f9497fb4ffd79eb8e docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md +47f0891fbab098ff73eec1437e3edc9aa9452a0e09171b595509129be0321909 project/CURRENT_PROJECT_STATE.md +7cacde2453c913fa2514b33961ff42ebfd7952e9ae809bd8457000ec034a39fd project/README.md +8d3c834769da7d236cafe8211ea85847f130a15de94cc6177b0287018f321c8c project/updates/v1.0.6.39-v1.0.6.40-production-readiness/03_PHASE_PROGRESS_COMPLETION_LEDGER.md +f15bc0d550722dc3ce65a62ab067cbc9164f9eba748f34c4e91985e4a9672437 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/04_ERROR_HANDLING_AUDIT_AND_PLAN.md +714c116784765b6b2a4f8e5c1aaf5f3a6c4210cf0a9cfd48dc56e3be671511b2 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/05_ACTUAL_IMPLEMENTATION_STATUS.md +3db19d7fced4da0d62e1cff7219c1ac6986c5990c277a26f2b68ab0ab0296b75 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/07_ACCEPTANCE_TEST_MATRIX.md +48cd5e78ac5583507e1ad54b2f15547526e39e9286c36933e5152c09fec72644 project/updates/v1.0.6.39-v1.0.6.40-production-readiness/08_REQUIREMENTS_TRACEABILITY_MATRIX.md +7800d0bb2edd848946420e0f57cfce362f461ba7e13da63ad78d9bb9ed3d341a project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/08_PHASE2_PROGRESS_LEDGER.md +9fc8d56a71d7974c8eb29f481ef637c09ca80edabbd1027cc6361460ca9952fd project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/09_ACTUAL_IMPLEMENTATION_STATUS.md +04cbb70c9c31be346ee1a6aaa2c520ddf23f163f40ab3a9e8a99f1b0abe829b1 project/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow/10_FINAL_VERIFICATION_REPORT.md +fa3ee8efabd172f948d0cf916d4ff15125985855b4775835e8b59bae5ea9688b project/updates/v1.0.6.43-phase2-forensic-closure/00_SCOPE_LOCK.md +affa217a6e2f09b6f23e60d7f68ea6bc966b6a9ba32d2458464a9ad8c1dcc074 project/updates/v1.0.6.43-phase2-forensic-closure/01_FORENSIC_AUDIT_REPORT.md +b2ce52ba801b854ad73caab6883574ac75fb9097ea6d80cc9eb11cbdb6dde875 project/updates/v1.0.6.43-phase2-forensic-closure/02_ROOT_CAUSE_AND_FIX_REPORT.md +37759d13937d1598741c8a5bc88c7dafc1a115b2b4e58230b5b4428aa618987d project/updates/v1.0.6.43-phase2-forensic-closure/03_VERIFICATION_MATRIX.md +11c64a20aaa114a4df9558538a95a2b5b7c1a2aeb0eb353282bfe92b4e3bb50e project/updates/v1.0.6.43-phase2-forensic-closure/04_ACTUAL_IMPLEMENTATION_STATUS.md +895eca262c66c06978acba6abf9696ac657e4c6c3872f535239d3988605cd562 pyproject.toml +c58c6f8ca2e7f2de427fa3e559249f30a48754f5fea999214b3d481e24d8fb8b scripts/verify_repository.py +087ae992e95a7778944cfc2a41d692bd19df237b2714eaeeb2b30d7723e6d982 src/vibrapilot/qt_app.py +175b95801aad71d3e01b17f0521cc20eb2ac90384fa00031ff3c52705217746d src/vibrapilot/workflow/plugin_loader.py +b346b90e433f7b1c728887d497783428f6ef2099d82cfacba2e73c8ee4c54f7e src/vibrapilot/workspace_state.py +b043e4573d61da2e2aa441e73b8e86ff64d58aa1fd1e2dca1c5a936c505a3a70 tests/test_app_config.py +2a748ec89fe7ab0ef259279418cf4cc662e1f2ca14d36ffe0eeced790b5c6697 tests/test_v10623_pr06_workflow_state_atomic_switch.py +819040a94cd41a2b156d12f6ccb8baf4e2d99eb26def4c91065b1f699b918fdb tests/test_v10624_pr07_workflow_showcase.py +37110669b838914b656a09f0c4552f82f923b300bda0d7f8c5ab0b055b40d26f tests/test_v10626_pr09_data_persistence_reporting_compatibility.py +7b7e9452dce6ce1e319bfa4966cdeae07773f2baa83b5ab3037757c374914233 tests/test_v10627_pr10_workflow_error_recovery.py +a42c3ef5020d08a0b67cdbb36f6bdd604536dd5e825693056cacd0923d785fbb tests/test_v10642_workflow_lifecycle.py +591135843c74f322df6403f5509b28b5956ae288727836ec8d40bcb70f39adfe tests/test_v10643_phase2_forensic_closure.py +3b483474b389953c16abf8b4c2581704c0a1a8ee0c9bf924c2f7aa0570e7846f vibproject.ygit diff --git a/UPDATE_LOG.md b/UPDATE_LOG.md index 576a5d4..21e7980 100644 --- a/UPDATE_LOG.md +++ b/UPDATE_LOG.md @@ -1,3 +1,13 @@ +# v1.0.6.43 — Phase 2 Forensic Closure Update Log — 2026-08-18 + +- Official baseline: v1.0.6.42 / `8c3e9a137ecf852aa152dba0590414e7f8f6209d` / tree `c61a9864e99659bf0a3d3cb229e1ad6e5e85cb69`. +- Baseline ZIP SHA-256: `0713798e061b0eef15c1a1d4cb52e1347687f7498419b9ab1a79b3017f31dae1`. +- Tests-first forensic reproduction: 8 concrete contract failures reproduced before correction. +- Corrective production scope: `qt_app.py`, `workflow/plugin_loader.py`, `workspace_state.py` only. +- Chrome, power, settings defaults, licensing, dependencies, CI and portable-release files remain byte-frozen from v1.0.6.42. + +--- + # v1.0.6.42 — Phase 2 Update Log — 2026-08-18 - Workflow lifecycle transaction/update/remove implementation added and tested fail-closed. diff --git a/VERSIONING.md b/VERSIONING.md index efb3b91..82afd3d 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -1,3 +1,13 @@ +# v1.0.6.43 Version Boundary + +- Official input: v1.0.6.42. +- Target: v1.0.6.43. +- Classification: Phase-2 forensic closure only; no new feature scope. +- Production changes are restricted to the three files explicitly authorized by `config/verification/v1.0.6.43_phase2_forensic_closure_scope.json`. +- v1.0.6.43 becomes the next local development baseline only after final automated verification and Delta sealing; public release status still requires GitHub CI and Windows live acceptance. + +--- + # v1.0.6.42 Version Boundary - Baseline: v1.0.6.41 / `615fe1148431b90334e9ff3f9ae02b37a36bd1d8`. diff --git a/config/AppConfig/app.py b/config/AppConfig/app.py index c18b4a8..3f5c259 100644 --- a/config/AppConfig/app.py +++ b/config/AppConfig/app.py @@ -12,7 +12,7 @@ DESCRIPTION = "VibraPilot browser automation desktop application by Vib Tools." TAGLINE = "Authorized browser automation desktop application." -VERSION = "1.0.6.42" +VERSION = "1.0.6.43" CREATED_DATE = "2026-08-07" RELEASE_DATE = "2026-08-18" UPDATED_DATE = "2026-08-18" diff --git a/config/verification/v1.0.6.43_phase2_forensic_closure_scope.json b/config/verification/v1.0.6.43_phase2_forensic_closure_scope.json new file mode 100644 index 0000000..b3a455d --- /dev/null +++ b/config/verification/v1.0.6.43_phase2_forensic_closure_scope.json @@ -0,0 +1,98 @@ +{ + "plan_id": "VP-V10643-PHASE2-FORENSIC-CLOSURE-001", + "classification": "Phase 2 forensic closure / workflow lifecycle safety and semantic consistency fixes only", + "baseline_version": "1.0.6.42", + "target_version": "1.0.6.43", + "baseline_commit": "8c3e9a137ecf852aa152dba0590414e7f8f6209d", + "baseline_tree": "c61a9864e99659bf0a3d3cb229e1ad6e5e85cb69", + "baseline_zip": "VibraPilot_Official_v1.0.6.42_Baseline(1).zip", + "baseline_zip_sha256": "0713798e061b0eef15c1a1d4cb52e1347687f7498419b9ab1a79b3017f31dae1", + "scope": "Forensically close only concrete v1.0.6.42 Phase-2 lifecycle/multiworkflow defects: restart-free compatibility switch, lifecycle transaction fail-closed validation, workspace preservation, legacy recoverable mutation blocking, live lifecycle mutation blocking and Default Workflow UI semantics.", + "allowed_production_source_changes": [ + "src/vibrapilot/qt_app.py", + "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workspace_state.py" + ], + "authorized_automationworker_method_changes": [], + "allowed_runtime_config_changes": [], + "plugin_api_version": 1, + "database_schema": { + "from": 2, + "to": 2, + "changes": 0 + }, + "workspace_schema": { + "from": 2, + "to": 2, + "correction": "preserve unresolved/unavailable Task shells and block autosave" + }, + "new_top_level_ui_pages": false, + "chrome_implementation_changes": 0, + "required_contracts": { + "normal_workflow_switch_remains_restart_free": true, + "historical_request_workflow_switch_is_restart_free_compatibility_wrapper": true, + "malformed_lifecycle_transaction_root_fails_closed": true, + "lifecycle_manifest_workflow_id_revalidated": true, + "schema_v2_missing_workflow_identity_preserved_fail_closed": true, + "unavailable_workspace_workflow_preserved_fail_closed": true, + "unresolved_legacy_recoverable_run_blocks_package_mutation": true, + "same_session_pending_lifecycle_blocks_task_and_browser": true, + "default_workflow_ui_semantics": true, + "plugin_api_1_preserved": true, + "chrome_secure_install_byte_frozen": true, + "no_chromium_fallback": true + }, + "frozen_file_sha256": { + "src/vibrapilot/backend.py": "a3badec5f9473aea559ead1f891fa561a6f90c764f76900d87c380c3de7733af", + "src/vibrapilot/task_runtime_store.py": "a5fd334d54d2a512f75f5c2f491727cf475673154a4583c70cada366b486e03a", + "src/vibrapilot/workflow/__init__.py": "a3242663312fef3fe44156f1b9ae3d5821bdb5d2de3d629e5814183615ce1f5a", + "src/vibrapilot/workflow/state.py": "e89f94635020db073f00a12c7dc536f2e8a564fc36aae1b1d1d9f5e8dd71099f", + "src/vibrapilot/workflow/manager.py": "d502314d9c17132b3d436b1bdbd73569e618a0f091c1e383cca58914de15bc49", + "src/vibrapilot/workflow/contracts.py": "5c3210746e7e79c905075a887d2fba2a1a9446e4f22bd127e763bd5a18490b2c", + "src/vibrapilot/workflow/schemas.py": "a2b5e48af9530e00206fd21c9e9adf16c7302aa2e0dce7ae0c6b74754458d1a1", + "src/vibrapilot/chrome_runtime.py": "f13a64271af900ddb4aaba7af348bed0607be772f5ca4e993bd9cd54173e9fcd", + "src/vibrapilot/chrome_installer.py": "bad5f96db58c04a5bb105dd3cdbfcefed654d699a971a2e0a460161c4377041b", + "src/vibrapilot/windows_authenticode.py": "6f03731087c2eaff23a4b92d25ea9a365c0e7ddef2cecd984824040f4771c6a0", + "src/vibrapilot/power_management.py": "69a8e54bafc825e45492a8e662acf0223adbbbb6617dd08991d16c8077eff6d2", + "src/vibrapilot/licensing_v2.py": "36435d05593785b7ec02310dcf2d031342524e95d5be6212591a09277699a91b", + "config/settings.defaults.json": "f0ed6cd3f24d6f18fd0445eaafd75136d128d52dcaca28f5fe438b71f95943ad", + "requirements.txt": "92890827d0d19fe07168cf801d15c96cf48b846813d7f687a04531e40ed2b083", + "requirements-portable.txt": "4c84508a5399ce66ed3d7393e672fa5658d6a55bb6f86ac386dce9ce27f4f861", + "requirements-build.txt": "39d98aacb5781de72933397e6c431b83a4b62aa1177798600db1907c8def53eb", + ".github/workflows/ci.yml": "a722955f9860315f77abdeb8b75cd1bfc269db24e8d46d437dd678917ba258a3", + ".github/workflows/portable-release.yml": "342bd815df0fa4e34170eaaecc5c47abc2619a9d600718b7f7ac564fa4586a62" + }, + "private_project_docs": { + "path": "project/updates/v1.0.6.43-phase2-forensic-closure/", + "github_public": false + }, + "authorized_nonproduction_files": [ + "config/AppConfig/app.py", + "pyproject.toml", + "CITATION.cff", + "vibproject.ygit", + "scripts/verify_repository.py", + "config/verification/v1.0.6.43_phase2_forensic_closure_scope.json", + "CHANGELOG.md", + "README.md", + "UPDATE_LOG.md", + "VERSIONING.md", + "ROADMAP.md", + "COMPATIBILITY.md", + "PROJECT_STRUCTURE.md", + "PATCH_MANIFEST.md", + "DELTA_FILE_LIST.txt", + "SHA256SUMS.txt", + "docs/index.md", + "docs/docs.manifest.ygit", + "docs/updates/v1.0.6.43-phase2-forensic-closure.md", + "docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md", + "tests/test_app_config.py", + "tests/test_v10623_pr06_workflow_state_atomic_switch.py", + "tests/test_v10624_pr07_workflow_showcase.py", + "tests/test_v10626_pr09_data_persistence_reporting_compatibility.py", + "tests/test_v10627_pr10_workflow_error_recovery.py", + "tests/test_v10642_workflow_lifecycle.py", + "tests/test_v10643_phase2_forensic_closure.py" + ] +} diff --git a/docs/docs.manifest.ygit b/docs/docs.manifest.ygit index cf84c39..65d1711 100644 --- a/docs/docs.manifest.ygit +++ b/docs/docs.manifest.ygit @@ -3,12 +3,12 @@ "documentation": { "id": "vibrapilot", "title": "VibraPilot Documentation", - "description": "Documentation for VibraPilot v1.0.6.42 Phase 2 Workflow Lifecycle Management + True Multiworkflow.", - "version": "1.0.6.42", + "description": "Documentation for VibraPilot v1.0.6.43 Phase 2 Forensic Closure.", + "version": "1.0.6.43", "root": "docs", "home": "index.md" }, "product": { - "description": "v1.0.6.42 completes restart-free workflow package update/remove/deactivate and immutable per-Task multiworkflow identity, adds workflow provenance to workspace/runtime reporting, and re-verifies the existing Chrome prerequisite secure-install implementation without changing it." + "description": "v1.0.6.43 closes proven Phase-2 lifecycle transaction, restart-free switch compatibility, workspace preservation and Default Workflow UI semantic gaps without changing the frozen Chrome secure-install implementation." } } diff --git a/docs/index.md b/docs/index.md index d82862e..553ffb7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,13 @@ +# VibraPilot Documentation — v1.0.6.43 Phase 2 Forensic Closure + +- Update: `docs/updates/v1.0.6.43-phase2-forensic-closure.md` +- Verification: `docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md` +- Scope contract: `config/verification/v1.0.6.43_phase2_forensic_closure_scope.json` + +This corrective seal preserves all v1.0.6.42 Phase-2 features while closing only reproduced lifecycle/multiworkflow consistency defects. + +--- + # VibraPilot Documentation — v1.0.6.42 Phase 2 - Update: `docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md` diff --git a/docs/updates/v1.0.6.43-phase2-forensic-closure.md b/docs/updates/v1.0.6.43-phase2-forensic-closure.md new file mode 100644 index 0000000..aa7e601 --- /dev/null +++ b/docs/updates/v1.0.6.43-phase2-forensic-closure.md @@ -0,0 +1,22 @@ +# v1.0.6.43 — Phase 2 Forensic Closure + +## Baseline + +- v1.0.6.42 commit: `8c3e9a137ecf852aa152dba0590414e7f8f6209d` +- tree: `c61a9864e99659bf0a3d3cb229e1ad6e5e85cb69` +- uploaded baseline SHA-256: `0713798e061b0eef15c1a1d4cb52e1347687f7498419b9ab1a79b3017f31dae1` + +## Confirmed corrective scope + +1. Historical workflow-switch service retained a destructive restart path instead of the approved restart-free default-workflow behavior. +2. Malformed lifecycle transaction root could raise an uncontrolled filesystem exception. +3. Lifecycle transaction `workflow_id` was not revalidated before recovery path construction. +4. Schema-v2 workspace Tasks with missing/unavailable workflow identity could be silently dropped and rewritten away. +5. Unresolved legacy recoverable runs did not reliably block package mutation. +6. Same-process pending lifecycle transactions did not block all new Task/browser paths. +7. Workflows UI still exposed global `ACTIVE` terminology instead of Phase-2 `DEFAULT` semantics. +8. Workflow install path contained duplicated staging-directory setup. + +## Frozen boundaries + +Chrome prerequisite/runtime/installer/AuthentiCode, power management, licensing, browser profiles, settings defaults, dependencies, CI, portable packaging and Plugin API 1 are unchanged. diff --git a/docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md b/docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md new file mode 100644 index 0000000..472f2f5 --- /dev/null +++ b/docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md @@ -0,0 +1,31 @@ +# v1.0.6.43 Phase 2 Forensic Closure Verification + +## Required gates + +- tests-first reproduction for every corrective defect; +- targeted v1.0.6.43 closure tests; +- Phase-1/Phase-2 workflow/persistence/Chrome regression; +- frozen-file SHA verification; +- repository verifier; +- full pytest; +- full unittest; +- compileall; +- `git diff --check`; +- fresh v1.0.6.42 + Delta overlay byte/hash verification. + +## External gates + +Real Windows live acceptance and GitHub v1.0.6.43 CI are external evidence and must not be fabricated as PASS before observed. + +## Automated result + +- tests-first baseline reproduction: 8 reproduced failures / 1 already-passing guard before correction; +- corrected v1.0.6.43 closure tests: 9 PASS; +- broader Phase-1/Phase-2/Chrome regression: 190 PASS; +- repository verifier: PASS; +- final pytest: 550 passed, 6 skipped, 105 subtests passed; +- final unittest: 201 OK, 6 skipped; +- compileall: PASS; +- `git diff --check`: PASS. + +The error-level lines emitted by corrupt-license, synthetic browser-launch and corrupt-SQLite negative-path tests are expected fixture evidence; the corresponding tests passed. diff --git a/pyproject.toml b/pyproject.toml index f34932d..59f7728 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vibrapilot" -version = "1.0.6.42" +version = "1.0.6.43" description = "VibraPilot browser automation desktop application by Vib Tools." requires-python = ">=3.12,<3.13" license = {text = "GPL-3.0-only"} diff --git a/scripts/verify_repository.py b/scripts/verify_repository.py index 3711460..a9287eb 100644 --- a/scripts/verify_repository.py +++ b/scripts/verify_repository.py @@ -61,6 +61,7 @@ V10640_PHASE1_FORENSIC_CLOSURE_SCOPE_CONTRACT = ROOT / "config" / "verification" / "v1.0.6.40_phase1_forensic_closure_fix_scope.json" V10641_PHASE1_ACTIVE_PAGE_ORIGIN_CLOSURE_SCOPE_CONTRACT = ROOT / "config" / "verification" / "v1.0.6.41_phase1_active_page_origin_closure_scope.json" V10642_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW_SCOPE_CONTRACT = ROOT / "config" / "verification" / "v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json" +V10643_PHASE2_FORENSIC_CLOSURE_SCOPE_CONTRACT = ROOT / "config" / "verification" / "v1.0.6.43_phase2_forensic_closure_scope.json" APP_CONFIG_ROOT = ROOT / "config" / "AppConfig" APP_CONFIG_APP = APP_CONFIG_ROOT / "app.py" @@ -448,11 +449,29 @@ def ast_contract_sha(node: ast.AST) -> str: ) v10642_worker_methods = set(v10642_scope.get("authorized_automationworker_method_changes", [])) +# v1.0.6.43 is a corrective forensic seal over Phase 2. It may change only +# the reproduced lifecycle/multiworkflow closure surfaces declared by its scope. +if not V10643_PHASE2_FORENSIC_CLOSURE_SCOPE_CONTRACT.is_file(): + fail("v1.0.6.43 Phase 2 forensic closure scope contract is missing") +try: + v10643_scope = json.loads( + V10643_PHASE2_FORENSIC_CLOSURE_SCOPE_CONTRACT.read_text(encoding="utf-8") + ) +except Exception as exc: + fail(f"v1.0.6.43 Phase 2 forensic closure scope contract is invalid: {exc}") +v10643_production_allowed = set(v10643_scope.get("allowed_production_source_changes", [])) +v10643_allowed_files = ( + v10643_production_allowed + | set(v10643_scope.get("allowed_runtime_config_changes", [])) + | set(v10643_scope.get("authorized_nonproduction_files", [])) +) +v10643_worker_methods = set(v10643_scope.get("authorized_automationworker_method_changes", [])) + current_worker_methods = ( - v10630_worker_methods | v10631_worker_methods | v10632_worker_methods | v10633_worker_methods | v10635_worker_methods | v10636_worker_methods | v10639_worker_methods | v10640_worker_methods | v10641_worker_methods | v10642_worker_methods + v10630_worker_methods | v10631_worker_methods | v10632_worker_methods | v10633_worker_methods | v10635_worker_methods | v10636_worker_methods | v10639_worker_methods | v10640_worker_methods | v10641_worker_methods | v10642_worker_methods | v10643_worker_methods ) current_allowed_files = ( - v10630_allowed_files | v10631_allowed_files | v10632_allowed_files | v10633_allowed_files | v10634_allowed_files | v10635_allowed_files | v10636_allowed_files | v10637_allowed_files | v10638_allowed_files | v10639_allowed_files | v10640_allowed_files | v10641_allowed_files | v10642_allowed_files + v10630_allowed_files | v10631_allowed_files | v10632_allowed_files | v10633_allowed_files | v10634_allowed_files | v10635_allowed_files | v10636_allowed_files | v10637_allowed_files | v10638_allowed_files | v10639_allowed_files | v10640_allowed_files | v10641_allowed_files | v10642_allowed_files | v10643_allowed_files ) if pr08_allowed_files != { "src/vibrapilot/workflow_inputs.py", @@ -2470,11 +2489,16 @@ def ast_contract_sha(node: ast.AST) -> str: ): if forbidden_text in qt_ui_text: fail(f"v1.0.6.34 non-essential UI description remains: {forbidden_text}") +workflow_status_marker = ( + 'status_badge("DEFAULT" if is_active else "AVAILABLE"' + if V10643_PHASE2_FORENSIC_CLOSURE_SCOPE_CONTRACT.is_file() + else 'status_badge("ACTIVE" if is_active else "AVAILABLE"' +) for required_marker in ( 'panel.setMinimumWidth(280)', 'panel.setMaximumWidth(360)', 'columns = 1 if compact else (3 if wide else 2)', - 'status_badge("ACTIVE" if is_active else "AVAILABLE"', + workflow_status_marker, ): if required_marker not in qt_ui_text: fail(f"v1.0.6.34 workflow compact-grid marker missing: {required_marker}") @@ -2900,6 +2924,86 @@ def ast_contract_sha(node: ast.AST) -> str: if 'launch_args["channel"] = "chromium"' in backend_text: fail("v1.0.6.42 must not reintroduce Chromium fallback") +# v1.0.6.43 Phase 2 forensic closure exact scope. +if v10643_scope.get("plan_id") != "VP-V10643-PHASE2-FORENSIC-CLOSURE-001": + fail("v1.0.6.43 Phase 2 forensic closure plan identifier mismatch") +if v10643_scope.get("baseline_version") != "1.0.6.42" or v10643_scope.get("target_version") != "1.0.6.43": + fail("v1.0.6.43 version boundary mismatch") +if v10643_scope.get("baseline_commit") != "8c3e9a137ecf852aa152dba0590414e7f8f6209d": + fail("v1.0.6.43 baseline commit mismatch") +if v10643_scope.get("baseline_tree") != "c61a9864e99659bf0a3d3cb229e1ad6e5e85cb69": + fail("v1.0.6.43 baseline tree mismatch") +if v10643_scope.get("baseline_zip_sha256") != "0713798e061b0eef15c1a1d4cb52e1347687f7498419b9ab1a79b3017f31dae1": + fail("v1.0.6.43 baseline ZIP hash mismatch") +expected_v10643_production = { + "src/vibrapilot/qt_app.py", + "src/vibrapilot/workflow/plugin_loader.py", + "src/vibrapilot/workspace_state.py", +} +if v10643_production_allowed != expected_v10643_production: + fail("v1.0.6.43 production source scope mismatch") +if v10643_worker_methods: + fail("v1.0.6.43 must not change AutomationWorker methods") +if v10643_scope.get("allowed_runtime_config_changes", []) != []: + fail("v1.0.6.43 must not change runtime settings defaults") +if v10643_scope.get("plugin_api_version") != 1: + fail("v1.0.6.43 must preserve external Workflow Plugin API 1") +if v10643_scope.get("new_top_level_ui_pages") is not False: + fail("v1.0.6.43 must not add a top-level navigation page") +if v10643_scope.get("chrome_implementation_changes") != 0: + fail("v1.0.6.43 Chrome implementation must remain frozen") +for relative, expected_sha in v10643_scope.get("frozen_file_sha256", {}).items(): + path = ROOT / relative + if not path.is_file() or sha256(path) != expected_sha: + fail(f"v1.0.6.43 frozen surface drift detected: {relative}") + +plugin_loader_v10643 = (SRC / "workflow" / "plugin_loader.py").read_text(encoding="utf-8") +workspace_v10643 = (SRC / "workspace_state.py").read_text(encoding="utf-8") +qt_v10643 = (SRC / "qt_app.py").read_text(encoding="utf-8") +for marker in ( + "def _validated_installed_workflow_id", + "Workflow lifecycle transaction root is not a directory", + 'context="Workflow lifecycle transaction"', +): + if marker not in plugin_loader_v10643: + fail(f"v1.0.6.43 lifecycle safety marker missing: {marker}") +duplicate_staging_setup = ( + "staging_parent.mkdir(parents=True, exist_ok=True)\n" + " staging_parent.mkdir(parents=True, exist_ok=True)" +) +if duplicate_staging_setup in plugin_loader_v10643: + fail("v1.0.6.43 workflow staging setup contains a duplicated consecutive mkdir") +for marker in ( + "self.migration_blocked = True", + "Workspace Task workflow identity could not be resolved", +): + if marker not in workspace_v10643: + fail(f"v1.0.6.43 workspace preservation marker missing: {marker}") +for marker in ( + "def _workflow_lifecycle_block_reason(self) -> str:", + "Workflow package mutation is blocked by unresolved legacy recoverable Task workflow identity", + 'status_badge("DEFAULT" if is_active else "AVAILABLE"', + 'setText("Default workflow runtime unavailable")', + "return self.request_default_workflow_switch(target)", +): + if marker not in qt_v10643: + fail(f"v1.0.6.43 Qt/lifecycle closure marker missing: {marker}") +request_switch_start = qt_v10643.index(" def request_workflow_switch(self, target_workflow_id: str) -> str:") +request_switch_end = qt_v10643.index(" def _refresh_report_workflow_filter", request_switch_start) +request_switch = qt_v10643[request_switch_start:request_switch_end] +for forbidden in ("_spawn_workflow_restart", "_finalize_committed_workflow_switch", "QTimer.singleShot(0, self.close)"): + if forbidden in request_switch: + fail(f"v1.0.6.43 historical switch compatibility path is not restart-free: {forbidden}") +if literal_assignment(SRC / "workflow" / "schemas.py", "WORKFLOW_PLUGIN_API_VERSION") != 1: + fail("v1.0.6.43 Workflow Plugin API changed unexpectedly") +if literal_assignment(ROOT / "src" / "vibrapilot" / "qt_app.py", "NAV_SECTIONS") != [ + "Dashboard", "Tasks", "Workflows", "Workflow Inputs", "Workflow Settings", + "Reports", "Live Logs", "App Settings", "Browser Settings", "About", +]: + fail("v1.0.6.43 top-level navigation drift detected") +if 'launch_args["channel"] = "chromium"' in backend_text: + fail("v1.0.6.43 must not reintroduce Chromium fallback") + app_version = literal_assignment(APP_CONFIG_APP, "VERSION") app_id = literal_assignment(APP_CONFIG_APP, "APP_ID") app_name = literal_assignment(APP_CONFIG_APP, "APP_NAME") @@ -2909,8 +3013,8 @@ def ast_contract_sha(node: ast.AST) -> str: license_identifier = literal_assignment(APP_CONFIG_APP, "LICENSE_IDENTIFIER") homepage_url = literal_assignment(APP_CONFIG_APP, "HOMEPAGE_URL") repository_url = literal_assignment(APP_CONFIG_APP, "REPOSITORY_URL") -if app_version != "1.0.6.42": - fail("AppConfig VERSION must be 1.0.6.42 for the Phase 2 workflow lifecycle/multiworkflow candidate") +if app_version != "1.0.6.43": + fail("AppConfig VERSION must be 1.0.6.43 for the Phase 2 forensic closure candidate") for name, value in { "APP_ID": app_id, "APP_NAME": app_name, @@ -3679,6 +3783,7 @@ def ast_contract_sha(node: ast.AST) -> str: "config/verification/v1.0.6.40_phase1_forensic_closure_fix_scope.json", "config/verification/v1.0.6.41_phase1_active_page_origin_closure_scope.json", "config/verification/v1.0.6.42_phase2_workflow_lifecycle_multiworkflow_scope.json", + "config/verification/v1.0.6.43_phase2_forensic_closure_scope.json", "src/vibrapilot/power_management.py", "src/vibrapilot/chrome_runtime.py", "src/vibrapilot/chrome_installer.py", @@ -3690,6 +3795,7 @@ def ast_contract_sha(node: ast.AST) -> str: "tests/test_v10642_multiworkflow_identity.py", "tests/test_v10642_multiworkflow_persistence.py", "tests/test_v10642_chrome_final_acceptance_contract.py", + "tests/test_v10643_phase2_forensic_closure.py", "src/vibrapilot/workflow/plugin_loader.py", "src/vibrapilot/workflow/schemas.py", "src/vibrapilot/workflow/settings_state.py", "src/vibrapilot/workflow/task_state.py", "config/verification/backend_v1.0.6_contract.json", "docs/index.md", "docs/updates/v1.0.6.31-chrome-only-runtime-foundation.md", @@ -3718,6 +3824,8 @@ def ast_contract_sha(node: ast.AST) -> str: "docs/verification/V1.0.6.41_PHASE1_ACTIVE_PAGE_ORIGIN_CLOSURE.md", "docs/updates/v1.0.6.42-phase2-workflow-lifecycle-multiworkflow.md", "docs/verification/V1.0.6.42_PHASE2_WORKFLOW_LIFECYCLE_MULTIWORKFLOW.md", + "docs/updates/v1.0.6.43-phase2-forensic-closure.md", + "docs/verification/V1.0.6.43_PHASE2_FORENSIC_CLOSURE.md", "scripts/diagnostics/verify_v10633_browser_forensic_closure.py", "scripts/diagnostics/verify_v10634_ui_compact_polish.py", "scripts/diagnostics/verify_v10635_workflow_scoped_test_safety.py", diff --git a/src/vibrapilot/qt_app.py b/src/vibrapilot/qt_app.py index 84eb838..040496c 100644 --- a/src/vibrapilot/qt_app.py +++ b/src/vibrapilot/qt_app.py @@ -2573,8 +2573,19 @@ def _restore_active_workspace_tasks(self, state: dict[str, Any]) -> None: run_id = str(entry.get("run_id", "") or "") target_url = str(entry.get("target_url", "") or "") if not workflow_id or self.workflow_catalog.get_workflow(workflow_id) is None: + self.workspace_store.migration_blocked = True + identity = workflow_id or "" + preservation_warning = ( + f"Workspace Task workflow identity {identity!r} is unavailable for slot {slot_id}. " + "The original workspace file is preserved and autosave is blocked so the Task shell is not erased." + ) + self.workspace_store.warning = ( + (self.workspace_store.warning + " " + preservation_warning).strip() + if self.workspace_store.warning + else preservation_warning + ) self.log_ui( - f"Task {slot_id}: workspace restore blocked because workflow {workflow_id or ''!r} is unavailable.", + f"Task {slot_id}: workspace restore blocked because workflow {identity!r} is unavailable.", "WARNING", ) continue @@ -2727,7 +2738,7 @@ def show_workspace(self) -> None: ) if self.workflow_runtime_error: self.log_ui( - f"Active workflow runtime is unavailable; browser automation is blocked: {self.workflow_runtime_error}", + f"Default workflow runtime is unavailable for new Tasks; existing Tasks retain independent workflow checks: {self.workflow_runtime_error}", "ERROR", ) if self.workflow_input_state_error: @@ -3219,14 +3230,12 @@ def _reload_workflow_catalog(self) -> None: self._refresh_workflow_runtime_error() def load_workflow_plugin(self) -> None: - if self.workflow_lifecycle_error or self._transaction_root_has_directories( - self.workflow_plugin_root / ".transactions" - ): + lifecycle_blocker = self._workflow_lifecycle_block_reason() + if lifecycle_blocker: _message( self, "Workflow lifecycle blocked", - "A workflow lifecycle transaction requires recovery/manual repair before loading or updating packages. " - + (self.workflow_lifecycle_error or ""), + lifecycle_blocker, "warning", ) return @@ -3287,13 +3296,22 @@ def _workflow_reserved_ids(self) -> set[str]: for item in WorkflowManager.with_builtin_workflows().list_workflows() } - def _workflow_reference_block_reason(self, workflow_id: str) -> str: - resolved = str(workflow_id or "").strip() + def _workflow_lifecycle_block_reason(self) -> str: + """Return a live fail-closed package-lifecycle blocker for this process.""" if self.workflow_lifecycle_error: return "Workflow lifecycle recovery is unresolved: " + self.workflow_lifecycle_error lifecycle_root = self.workflow_plugin_root / ".transactions" - if self._transaction_root_has_directories(lifecycle_root): + if lifecycle_root.exists() and not lifecycle_root.is_dir(): + return "Workflow lifecycle transaction root is malformed; manual repair is required." + if lifecycle_root.is_dir() and any(lifecycle_root.iterdir()): return "A workflow lifecycle transaction is still present; restart or manual repair is required." + return "" + + def _workflow_reference_block_reason(self, workflow_id: str) -> str: + resolved = str(workflow_id or "").strip() + lifecycle_blocker = self._workflow_lifecycle_block_reason() + if lifecycle_blocker: + return lifecycle_blocker open_slots = sorted( task.slot_id for task in self.tasks.values() if task.workflow_id == resolved ) @@ -3306,11 +3324,28 @@ def _workflow_reference_block_reason(self, workflow_id: str) -> str: for row in self.runtime_store.closed_runs() if row.get("completed_at") is None ) + unresolved_legacy_runs: list[str] = [] for row in rows: - if str(row.get("workflow_id", "") or "").strip() == resolved: - run_id = str(row.get("run_id", "") or "") - if run_id: - referenced_runs.append(run_id[:12]) + run_id = str(row.get("run_id", "") or "") + row_workflow_id = str(row.get("workflow_id", "") or "").strip() + if not row_workflow_id: + try: + slot_id = int(row.get("slot_id", 0)) + except (TypeError, ValueError): + slot_id = 0 + row_workflow_id = str( + self._resolve_legacy_workspace_workflow_identity(slot_id, run_id) or "" + ).strip() + if not row_workflow_id: + unresolved_legacy_runs.append(run_id[:12] or f"slot-{slot_id}") + continue + if row_workflow_id == resolved and run_id: + referenced_runs.append(run_id[:12]) + if unresolved_legacy_runs: + return ( + "Workflow package mutation is blocked by unresolved legacy recoverable Task workflow identity: " + + ", ".join(sorted(set(unresolved_legacy_runs))) + ) if referenced_runs: return "Workflow is referenced by recoverable/unfinished closed Task runtime(s): " + ", ".join(sorted(set(referenced_runs))) return "" @@ -3439,10 +3474,11 @@ def request_default_workflow_switch(self, target_workflow_id: str | None) -> str "Workflow default change is blocked by unresolved recovery state: " + self.workflow_recovery_error ) - if self.workflow_lifecycle_error: + lifecycle_blocker = self._workflow_lifecycle_block_reason() + if lifecycle_blocker: raise WorkflowSwitchBlockedError( "Workflow default change is blocked by unresolved lifecycle state: " - + self.workflow_lifecycle_error + + lifecycle_blocker ) if self.workflow_state_error: raise WorkflowSwitchBlockedError( @@ -3538,7 +3574,7 @@ def _workflow_card(self, manifest: Any, *, active_workflow_id: str | None, state header_lay.addWidget(identity, 1) is_active = bool(state_available and active_workflow_id == manifest.workflow_id) - badge = status_badge("ACTIVE" if is_active else "AVAILABLE", "success" if is_active else "info") + badge = status_badge("DEFAULT" if is_active else "AVAILABLE", "success" if is_active else "info") badge.setObjectName("WorkflowStatusBadge") header_lay.addWidget(badge, 0, Qt.AlignTop | Qt.AlignRight) lay.addWidget(header) @@ -3697,9 +3733,10 @@ def refresh_workflow_showcase(self) -> None: ) self.workflow_showcase_notice.show() elif self.workflow_runtime_error: - self.workflow_showcase_notice_title.setText("Active workflow runtime unavailable") + self.workflow_showcase_notice_title.setText("Default workflow runtime unavailable") self.workflow_showcase_notice_text.setText( - "Browser automation is blocked, but switching to another valid registered workflow remains allowed. " + "The default workflow for new Tasks is unavailable. Existing Tasks bound to other valid workflows remain independent; " + "select another default or repair this workflow. " + self.workflow_runtime_error ) self.workflow_showcase_notice.show() @@ -4621,8 +4658,9 @@ def _workflow_runtime_error_for(self, workflow_id: str) -> str: return "" def add_task(self) -> None: - if self.workflow_lifecycle_error: - _message(self, "Workflow lifecycle blocked", self.workflow_lifecycle_error, "warning") + lifecycle_blocker = self._workflow_lifecycle_block_reason() + if lifecycle_blocker: + _message(self, "Workflow lifecycle blocked", lifecycle_blocker, "warning") return if self.workflow_state_error: _message(self, "Workflow unavailable", self.workflow_state_error, "warning") @@ -4995,10 +5033,11 @@ def can_open_task_browser(self, slot: TaskSlotWidget) -> tuple[bool, str]: ) if self._workflow_restart_required: return False, "Workflow change is committed. Restart VibraPilot before opening automation browsers." - if self.workflow_lifecycle_error: + lifecycle_blocker = self._workflow_lifecycle_block_reason() + if lifecycle_blocker: return False, ( "Workflow lifecycle recovery is unresolved. Automation is fail-closed until manual repair. " - + self.workflow_lifecycle_error + + lifecycle_blocker ) if self.workflow_recovery_error: return False, ( @@ -5364,153 +5403,24 @@ def _spawn_workflow_restart(self) -> None: subprocess.Popen(command, cwd=cwd) def request_workflow_switch(self, target_workflow_id: str) -> str: - """Execute the PR-06 fail-closed atomic switch transaction. + """Compatibility service for historical callers using restart-free Phase-2 semantics. - PR-07 may call this service from future Workflow UI. PR-06 itself adds no - workflow-selection page or activation button. + v1.0.6.42 changed the persisted global workflow identity into the default + for newly created Tasks. Existing Tasks own immutable workflow identities, + so this compatibility path must never clear Tasks or restart VibraPilot. """ - target = str(target_workflow_id).strip() + target = str(target_workflow_id or "").strip() if not target: raise WorkflowSwitchBlockedError("Target workflow ID is empty.") - if self._workflow_switch_in_progress: - raise WorkflowSwitchBlockedError( - "Another workflow switch transaction is already in progress." - ) - try: - persisted = self.workflow_state_store.load_existing() - except WorkflowStateError as exc: - self.workflow_state_error = str(exc) - raise WorkflowSwitchBlockedError( - f"Active workflow state is unavailable: {exc}" - ) from exc - current = persisted.active_workflow_id - - # Validate the installed target before either first activation or a switch. - self.workflow_catalog.require_workflow(target) - self.workflow_catalog.require_runtime_factory(target) - try: - self.workflow_catalog.input_schema(target) - self.workflow_catalog.settings_schema(target) - self.workflow_catalog.task_schema(target) - except WorkflowError as exc: - raise WorkflowSwitchBlockedError( - f"Target workflow schema is unavailable: {exc}" - ) from exc - - if target == current: - self._initialize_active_workflow_state_if_available() - self._refresh_workflow_runtime_error() - return "already_active" - - if current is None: - blocker = self._workflow_recovery_block_reason() - if blocker: - raise WorkflowSwitchBlockedError(blocker) - if not self._confirm_first_workflow_activation(target): - return "cancelled" - new_state = self.workflow_state_store.commit_active_workflow( - target, expected_current_workflow_id=None - ) - self._finalize_committed_workflow_switch(new_state.active_workflow_id) - self._workflow_restart_required = True - try: - self._spawn_workflow_restart() - except Exception as exc: - logging.exception("First workflow activation restart spawn failed") - _message( - self, - "Manual restart required", - "Workflow activation was committed successfully, but VibraPilot could not " - f"start the replacement process. Restart VibraPilot manually.\n\n{exc}", - "error", - ) - return "committed_restart_required" - QTimer.singleShot(0, self.close) - return "switched" - - blocker = self._workflow_switch_block_reason() - if blocker: - raise WorkflowSwitchBlockedError(blocker) - if not self._confirm_workflow_switch(current, target): - return "cancelled" - - # Persist the latest approved workspace snapshot before rollback staging. - if self.workspace_save_timer.isActive(): - self.workspace_save_timer.stop() - self.save_workspace_state() - workspace_snapshot = self._workspace_snapshot() - settings_snapshot = dict(self.settings.data) - - self._workflow_switch_in_progress = True - transaction = WorkflowSwitchTransaction( - data_root=APP_DATA_DIR, - transaction_root=self.workflow_switch_root, - old_workflow_id=current, - target_workflow_id=target, - ) - committed = False - try: - if not self._settle_workflow_workers(): + if target != self.active_workflow_id: + runtime_error = self._workflow_runtime_error_for(target) + if runtime_error: raise WorkflowSwitchBlockedError( - "One or more Task workers could not settle safely; workflow switch aborted." - ) - transaction.prepare(self._workflow_switch_paths()) - try: - self._clear_workflow_scoped_state(workspace_snapshot) - new_state = self.workflow_state_store.commit_active_workflow( - target, expected_current_workflow_id=current - ) - committed = True - except BaseException: - transaction.rollback() - self._restore_after_failed_workflow_switch(settings_snapshot) - raise - - # The workflow-state os.replace above is the commit point. From here - # onward the target workflow is authoritative and old data must not be - # restored, even if restart spawning fails. - try: - transaction.mark_committed() - except Exception as exc: - logging.exception("Workflow switch committed but transaction marker update failed") - self.log_ui( - f"Workflow switch committed; transaction cleanup will be recovered on restart: {exc}", - "WARNING", - ) - self._finalize_committed_workflow_switch(new_state.active_workflow_id) - try: - transaction.cleanup() - except Exception as exc: - logging.exception("Committed workflow switch staging cleanup failed") - self.log_ui( - f"Workflow switch committed; stale transaction staging will be cleaned on restart: {exc}", - "WARNING", - ) - - self._workflow_restart_required = True - try: - self._spawn_workflow_restart() - except Exception as exc: - logging.exception("Workflow switch restart spawn failed") - _message( - self, - "Manual restart required", - "Workflow switch was committed successfully, but VibraPilot could not " - f"start the replacement process. Restart VibraPilot manually.\n\n{exc}", - "error", + "Target workflow runtime/schema is unavailable: " + runtime_error ) - return "committed_restart_required" - - QTimer.singleShot(0, self.close) - return "switched" - except WorkflowSwitchBlockedError: - if not committed: - self._workflow_switch_in_progress = False - raise - except BaseException: - if not committed: - self._workflow_switch_in_progress = False - raise + if not self._confirm_default_workflow_switch(target): + return "cancelled" + return self.request_default_workflow_switch(target) def _refresh_report_workflow_filter(self) -> None: if not hasattr(self, "report_workflow"): diff --git a/src/vibrapilot/workflow/plugin_loader.py b/src/vibrapilot/workflow/plugin_loader.py index 56c4726..3dec2d1 100644 --- a/src/vibrapilot/workflow/plugin_loader.py +++ b/src/vibrapilot/workflow/plugin_loader.py @@ -14,6 +14,7 @@ import inspect import json import os +import re from pathlib import Path, PurePosixPath import shutil import stat @@ -512,6 +513,20 @@ def install_workflow_package( WORKFLOW_LIFECYCLE_TRANSACTION_SCHEMA_VERSION = 1 _LIFECYCLE_PREPARED = "PREPARED" _LIFECYCLE_COMMITTED = "COMMITTED" +_INSTALLED_WORKFLOW_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") + + +def _validated_installed_workflow_id(value: str, *, context: str) -> str: + normalized = str(value or "").strip() + if not _INSTALLED_WORKFLOW_ID_RE.fullmatch(normalized): + if context == "Workflow remove": + raise WorkflowPluginInstallError( + f"Workflow remove ID is not a safe installed workflow identifier: {normalized!r}." + ) + raise WorkflowPluginInstallError( + f"{context} workflow identity is invalid: {normalized!r}." + ) + return normalized def _version_tuple(value: str) -> tuple[int, int, int, int]: @@ -534,6 +549,10 @@ def _lifecycle_root(workflow_root: Path) -> Path: def _assert_lifecycle_idle(workflow_root: Path) -> None: tx_root = _lifecycle_root(workflow_root) + if tx_root.exists() and not tx_root.is_dir(): + raise WorkflowPluginInstallError( + "Workflow lifecycle transaction root is not a directory; manual repair is required." + ) if tx_root.is_dir() and any(tx_root.iterdir()): raise WorkflowPluginInstallError( "A pending workflow lifecycle transaction must be recovered before another package mutation." @@ -604,6 +623,10 @@ def recover_workflow_lifecycle_transactions(workflow_root: Path) -> list[str]: tx_root = _lifecycle_root(root) if not tx_root.exists(): return [] + if not tx_root.is_dir(): + raise WorkflowPluginInstallError( + "Workflow lifecycle transaction root is not a directory; manual repair is required." + ) unexpected = sorted(path.name for path in tx_root.iterdir() if not path.is_dir()) if unexpected: raise WorkflowPluginInstallError( @@ -629,12 +652,21 @@ def recover_workflow_lifecycle_transactions(workflow_root: Path) -> list[str]: ) status = str(payload.get("status", "")) action = str(payload.get("action", "")) - workflow_id = str(payload.get("workflow_id", "")).strip() - if action not in {"update", "remove"} or not workflow_id: + if action not in {"update", "remove"}: raise WorkflowPluginInstallError( f"Workflow lifecycle transaction identity is invalid: {tx.name}." ) - destination = root / workflow_id + workflow_id = _validated_installed_workflow_id( + str(payload.get("workflow_id", "")), + context="Workflow lifecycle transaction", + ) + destination = (root / workflow_id).resolve() + try: + destination.relative_to(root) + except ValueError as exc: + raise WorkflowPluginInstallError( + f"Workflow lifecycle transaction identity is invalid: {tx.name}." + ) from exc backup = tx / "backup" if status == _LIFECYCLE_PREPARED: if backup.exists(): @@ -792,17 +824,11 @@ def update_workflow_package( def remove_installed_workflow(workflow_id: str, workflow_root: Path) -> WorkflowManifest: """Atomically remove one installed workflow package directory only.""" - normalized = str(workflow_id).strip() + normalized = _validated_installed_workflow_id( + str(workflow_id), context="Workflow remove" + ) root = Path(workflow_root).expanduser().resolve() _assert_lifecycle_idle(root) - if ( - not normalized - or normalized in {".", ".."} - or "/" in normalized - or "\\" in normalized - or ":" in normalized - ): - raise WorkflowPluginInstallError("Workflow remove ID is not a safe installed workflow identifier.") destination = (root / normalized).resolve() try: destination.relative_to(root) diff --git a/src/vibrapilot/workspace_state.py b/src/vibrapilot/workspace_state.py index fef18fc..f454c45 100644 --- a/src/vibrapilot/workspace_state.py +++ b/src/vibrapilot/workspace_state.py @@ -91,10 +91,11 @@ def _normalized_state(self, raw: dict[str, Any], *, schema_version: int) -> tupl raw_tasks = raw.get("active_tasks", []) if isinstance(raw_tasks, list): for item in raw_tasks: - normalized = self._normalize_task( - item, - require_workflow=schema_version == WORKSPACE_STATE_SCHEMA_VERSION, - ) + # Parse the Task shell first without discarding a missing workflow + # identity. Schema-v2 requires workflow_id, but preserving the + # original file and blocking autosave is safer than silently erasing + # a Task shell whose identity became unavailable/corrupt. + normalized = self._normalize_task(item, require_workflow=False) if normalized is None: continue slot_id = int(normalized["slot_id"]) diff --git a/tests/test_app_config.py b/tests/test_app_config.py index 9939b87..995434c 100644 --- a/tests/test_app_config.py +++ b/tests/test_app_config.py @@ -24,7 +24,7 @@ def test_authoritative_identity(self): self.assertEqual(APP.app_id, "vibrapilot") self.assertEqual(APP.app_name, "VibraPilot") self.assertEqual(APP.display_name, "VibraPilot") - self.assertEqual(APP.version, "1.0.6.42") + self.assertEqual(APP.version, "1.0.6.43") self.assertEqual(APP.owner_name, "Vib Tools") self.assertEqual(APP.license_identifier, "GPL-3.0-only") self.assertEqual(APP.updated_date, "2026-08-18") diff --git a/tests/test_v10623_pr06_workflow_state_atomic_switch.py b/tests/test_v10623_pr06_workflow_state_atomic_switch.py index bfdae39..864a00c 100644 --- a/tests/test_v10623_pr06_workflow_state_atomic_switch.py +++ b/tests/test_v10623_pr06_workflow_state_atomic_switch.py @@ -318,34 +318,37 @@ def test_same_workflow_is_noop_before_confirmation_or_mutation(): n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "request_workflow_switch" ) segment = ast.get_source_segment(source, method) or "" - assert 'if target == current:' in segment - assert 'return "already_active"' in segment - assert segment.index('return "already_active"') < segment.index("_confirm_workflow_switch") - assert segment.index('return "already_active"') < segment.index("transaction.prepare") - + # v1.0.6.42+ supersedes destructive global switching: the historical service + # remains as a compatibility wrapper over the restart-free default workflow. + assert "request_default_workflow_switch(target)" in segment + assert "if target != self.active_workflow_id:" in segment + assert "_spawn_workflow_restart" not in segment + assert "_clear_workflow_scoped_state" not in segment def test_switch_blockers_confirmation_commit_and_restart_order_are_present(): source = (ROOT / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") tree = ast.parse(source) cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "MainWindow") - method = next( - n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "request_workflow_switch" - ) - segment = ast.get_source_segment(source, method) or "" + methods = { + n.name: ast.get_source_segment(source, n) or "" + for n in cls.body if isinstance(n, ast.FunctionDef) + } + switch = methods["request_workflow_switch"] + assert "_workflow_runtime_error_for(target)" in switch + assert "_confirm_default_workflow_switch(target)" in switch + assert "request_default_workflow_switch(target)" in switch + assert "_spawn_workflow_restart" not in switch + # Historical destructive transaction primitives remain only for explicit + # workflow-state recovery compatibility, not normal Phase-2 switching. + recovery = methods["request_workflow_state_recovery"] for marker in ( - "_workflow_switch_block_reason()", - "_confirm_workflow_switch(current, target)", "_settle_workflow_workers()", "transaction.prepare(", "_clear_workflow_scoped_state(", - "commit_active_workflow(", "transaction.mark_committed()", "_spawn_workflow_restart()", ): - assert marker in segment - assert segment.index("_confirm_workflow_switch") < segment.index("transaction.prepare") - assert segment.index("commit_active_workflow") < segment.index("_spawn_workflow_restart") - + assert marker in recovery def test_clear_policy_is_explicit_and_preserve_paths_are_not_deleted(): source = (ROOT / "src/vibrapilot/qt_app.py").read_text(encoding="utf-8") diff --git a/tests/test_v10624_pr07_workflow_showcase.py b/tests/test_v10624_pr07_workflow_showcase.py index a1eee7c..82b5825 100644 --- a/tests/test_v10624_pr07_workflow_showcase.py +++ b/tests/test_v10624_pr07_workflow_showcase.py @@ -126,7 +126,7 @@ def test_card_preserves_manifest_identity_version_source_logo_and_readable_descr def test_active_workflow_uses_status_badge_without_duplicate_disabled_active_action(): source = _source("_workflow_card") - assert 'status_badge("ACTIVE" if is_active else "AVAILABLE"' in source + assert 'status_badge("DEFAULT" if is_active else "AVAILABLE"' in source assert 'button("Active", "secondary")' not in source assert 'action.setObjectName("WorkflowActiveButton")' not in source diff --git a/tests/test_v10626_pr09_data_persistence_reporting_compatibility.py b/tests/test_v10626_pr09_data_persistence_reporting_compatibility.py index 5293a10..5426146 100644 --- a/tests/test_v10626_pr09_data_persistence_reporting_compatibility.py +++ b/tests/test_v10626_pr09_data_persistence_reporting_compatibility.py @@ -165,25 +165,26 @@ def test_switch_clear_and_preserve_boundary_is_exact_and_no_wrong_workflow_recov assert preserved not in switch_paths assert preserved not in clear_state assert "for key in WORKFLOW_INPUT_KEYS" in clear_state - assert "transaction.prepare(self._workflow_switch_paths())" in switch - assert "transaction.rollback()" in switch - assert "self._restore_after_failed_workflow_switch(settings_snapshot)" in switch - assert "old data must not be" in switch and "restored" in switch + # v1.0.6.42+ keeps the historical destructive clear transaction only for + # explicit recovery; normal workflow switching is restart-free/default-only. + assert "transaction.prepare(self._workflow_switch_paths())" not in switch + assert "_clear_workflow_scoped_state" not in switch + assert "request_default_workflow_switch(target)" in switch def test_same_workflow_cancel_and_blockers_precede_destructive_clear(): switch = _mainwindow_method_source("request_workflow_switch") - assert switch.index('if target == current:') < switch.index("transaction.prepare") - assert switch.index('return "already_active"') < switch.index("transaction.prepare") - assert switch.index("_workflow_switch_block_reason()") < switch.index("transaction.prepare") - assert switch.index('_confirm_workflow_switch(current, target)') < switch.index("transaction.prepare") - assert switch.index('return "cancelled"') < switch.index("transaction.prepare") + assert "if target != self.active_workflow_id:" in switch + assert "_confirm_default_workflow_switch(target)" in switch + assert "request_default_workflow_switch(target)" in switch + assert "transaction.prepare" not in switch + assert "_clear_workflow_scoped_state" not in switch + assert "_spawn_workflow_restart" not in switch blocker = _mainwindow_method_source("_workflow_switch_block_reason") for marker in ("workflow_state_error", "workflow_input_state_error", "is_running()", "manual_review_required"): assert marker in blocker - def test_historical_pr09_contract_is_preserved_but_v10636_registry_is_zero_builtin(): assert "return (SHARE_INVITE_MANIFEST,)" not in REGISTRY_SOURCE assert 'ShareInviteWorkflow' not in REGISTRY_SOURCE diff --git a/tests/test_v10627_pr10_workflow_error_recovery.py b/tests/test_v10627_pr10_workflow_error_recovery.py index b5e9d3d..cf7561d 100644 --- a/tests/test_v10627_pr10_workflow_error_recovery.py +++ b/tests/test_v10627_pr10_workflow_error_recovery.py @@ -133,7 +133,7 @@ def test_runtime_error_blocks_browser_but_is_not_a_switch_away_blocker(): switch = _method_source("request_workflow_switch") assert "_workflow_runtime_error_for(slot.workflow_id)" in can_open assert "workflow_runtime_error" not in switch_block - assert "require_runtime_factory(target)" in switch + assert "_workflow_runtime_error_for(target)" in switch def test_unresolved_recovery_hard_blocks_browser_and_switching(): diff --git a/tests/test_v10642_workflow_lifecycle.py b/tests/test_v10642_workflow_lifecycle.py index 00a96b0..c70d119 100644 --- a/tests/test_v10642_workflow_lifecycle.py +++ b/tests/test_v10642_workflow_lifecycle.py @@ -193,9 +193,12 @@ def test_load_is_fail_closed_when_lifecycle_recovery_is_unresolved(): cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "MainWindow") method = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "load_workflow_plugin") source = ast.get_source_segment(qt, method) or "" - assert "self.workflow_lifecycle_error" in source - assert 'self.workflow_plugin_root / ".transactions"' in source - assert source.index("workflow_lifecycle_error") < source.index("QFileDialog.getOpenFileName") + helper = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "_workflow_lifecycle_block_reason") + helper_source = ast.get_source_segment(qt, helper) or "" + assert "_workflow_lifecycle_block_reason" in source + assert "self.workflow_lifecycle_error" in helper_source + assert 'self.workflow_plugin_root / ".transactions"' in helper_source + assert source.index("_workflow_lifecycle_block_reason") < source.index("QFileDialog.getOpenFileName") def test_unresolved_lifecycle_transaction_blocks_browser_automation_and_new_tasks(): @@ -204,8 +207,10 @@ def test_unresolved_lifecycle_transaction_blocks_browser_automation_and_new_task tree = ast.parse(qt) cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "MainWindow") methods = {n.name: ast.get_source_segment(qt, n) or "" for n in cls.body if isinstance(n, ast.FunctionDef)} - assert "self.workflow_lifecycle_error" in methods["can_open_task_browser"] - assert "self.workflow_lifecycle_error" in methods["add_task"] + assert "_workflow_lifecycle_block_reason" in methods["can_open_task_browser"] + assert "_workflow_lifecycle_block_reason" in methods["add_task"] + assert "self.workflow_lifecycle_error" in methods["_workflow_lifecycle_block_reason"] + assert 'self.workflow_plugin_root / ".transactions"' in methods["_workflow_lifecycle_block_reason"] assert "Workflow lifecycle recovery" in qt diff --git a/tests/test_v10643_phase2_forensic_closure.py b/tests/test_v10643_phase2_forensic_closure.py new file mode 100644 index 0000000..ffc27c0 --- /dev/null +++ b/tests/test_v10643_phase2_forensic_closure.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +QT_PATH = ROOT / "src" / "vibrapilot" / "qt_app.py" +PLUGIN_PATH = ROOT / "src" / "vibrapilot" / "workflow" / "plugin_loader.py" + + +def _mainwindow_method_source(name: str) -> str: + text = QT_PATH.read_text(encoding="utf-8") + tree = ast.parse(text) + cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "MainWindow") + method = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == name) + return ast.get_source_segment(text, method) or "" + + +def test_legacy_request_workflow_switch_is_restart_free_compatibility_path(): + source = _mainwindow_method_source("request_workflow_switch") + assert "request_default_workflow_switch" in source + assert "_spawn_workflow_restart" not in source + assert "_clear_workflow_scoped_state" not in source + assert "_finalize_committed_workflow_switch" not in source + assert "QTimer.singleShot(0, self.close)" not in source + + +def test_lifecycle_transaction_root_file_fails_closed_with_controlled_error(tmp_path: Path): + from vibrapilot.workflow import WorkflowPluginInstallError, recover_workflow_lifecycle_transactions + + root = tmp_path / "Workflows" + root.mkdir() + (root / ".transactions").write_text("not-a-directory", encoding="utf-8") + with pytest.raises(WorkflowPluginInstallError, match="transaction root"): + recover_workflow_lifecycle_transactions(root) + + +def test_lifecycle_recovery_rejects_unsafe_workflow_id_before_path_use(tmp_path: Path): + from vibrapilot.workflow import WorkflowPluginInstallError, recover_workflow_lifecycle_transactions + + root = tmp_path / "Workflows" + tx = root / ".transactions" / "bad-update" + tx.mkdir(parents=True) + (tx / "transaction.json").write_text( + json.dumps( + { + "schema_version": 1, + "status": "PREPARED", + "action": "update", + "workflow_id": "../escape", + "target_version": "2.0.0", + "created_at": "2026-08-18T00:00:00+00:00", + } + ), + encoding="utf-8", + ) + with pytest.raises(WorkflowPluginInstallError, match="identity"): + recover_workflow_lifecycle_transactions(root) + assert tx.exists() + + +def test_schema_v2_workspace_missing_workflow_identity_blocks_preserving_save(tmp_path: Path): + from vibrapilot.workspace_state import WorkspaceStateStore + + path = tmp_path / "state.json" + original = { + "schema_version": 2, + "saved_at": "now", + "active_tasks": [ + {"slot_id": 4, "workflow_id": "", "run_id": "run4", "target_url": "https://example.test"} + ], + "next_slot_id": 5, + "selected_page": "Tasks", + "window": {}, + } + path.write_text(json.dumps(original), encoding="utf-8") + store = WorkspaceStateStore(path) + state = store.load() + assert state is not None + assert state["active_tasks"] == [] + assert store.migration_blocked is True + assert "workflow identity" in store.warning.lower() + assert json.loads(path.read_text(encoding="utf-8"))["active_tasks"][0]["run_id"] == "run4" + + +def test_unavailable_workspace_workflow_blocks_autosave_instead_of_erasing_task_shell(): + source = _mainwindow_method_source("_restore_active_workspace_tasks") + unavailable = source[source.index("if not workflow_id or self.workflow_catalog.get_workflow(workflow_id) is None:"):] + unavailable = unavailable[: unavailable.index("if run_id and run_id in closed_run_ids:")] + assert "self.workspace_store.migration_blocked = True" in unavailable + assert "workflow identity" in unavailable.lower() + + +def test_unresolved_legacy_recoverable_run_blocks_workflow_package_mutation(): + source = _mainwindow_method_source("_workflow_reference_block_reason") + assert "_resolve_legacy_workspace_workflow_identity" in source + assert "unresolved" in source.lower() + + +def test_live_lifecycle_transaction_blocks_new_tasks_and_browser_open(): + add_task = _mainwindow_method_source("add_task") + can_open = _mainwindow_method_source("can_open_task_browser") + assert "_workflow_lifecycle_block_reason" in add_task + assert "_workflow_lifecycle_block_reason" in can_open + + +def test_phase2_visible_global_workflow_identity_is_labeled_default_not_active(): + qt = QT_PATH.read_text(encoding="utf-8") + assert 'workflow_card = card("Default Workflow")' in qt + card_source = _mainwindow_method_source("_workflow_card") + assert '"DEFAULT" if is_active else "AVAILABLE"' in card_source + assert "Active workflow runtime unavailable" not in _mainwindow_method_source("refresh_workflow_showcase") + + +def test_install_workflow_package_has_single_staging_parent_creation(): + text = PLUGIN_PATH.read_text(encoding="utf-8") + tree = ast.parse(text) + method = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "install_workflow_package") + source = ast.get_source_segment(text, method) or "" + assert source.count("staging_parent.mkdir(parents=True, exist_ok=True)") == 1 diff --git a/vibproject.ygit b/vibproject.ygit index e74eb5d..57bae08 100644 --- a/vibproject.ygit +++ b/vibproject.ygit @@ -8,8 +8,8 @@ "slug": "vibrapilot", "displayName": "VibraPilot", "description": "VibraPilot browser automation desktop application by Vib Tools.", - "summary": "VibraPilot v1.0.6.42 completes restart-free workflow lifecycle management and true per-Task multiworkflow with workflow-aware persistence, recovery, reporting and dashboard isolation while preserving Plugin API 1 and the existing Chrome secure-install architecture.", - "version": "1.0.6.42", + "summary": "VibraPilot v1.0.6.43 closes concrete Phase-2 workflow lifecycle safety and default-workflow semantic gaps while preserving the v1.0.6.42 multiworkflow architecture and all frozen Chrome, licensing, power, dependency, CI and packaging surfaces.", + "version": "1.0.6.43", "status": "production", "visibility": "private", "category": "desktop-application",