@ngocsangairvds/vsaf 3.1.27 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/global.js +70 -10
- package/tools/skills/vds-scripts-skill/.openskills.json +6 -0
- package/tools/skills/vds-scripts-skill/QUALITY.md +44 -0
- package/tools/skills/vds-scripts-skill/SKILL.md +135 -0
- package/tools/skills/vds-scripts-skill/references/audit-commands.md +171 -0
- package/tools/skills/vds-scripts-skill/references/capability-index.md +34 -0
- package/tools/skills/vds-scripts-skill/references/development-commands.md +12 -0
- package/tools/skills/vds-scripts-skill/references/google-sheets.md +73 -0
- package/tools/skills/vds-scripts-skill/references/integration-commands.md +17 -0
- package/tools/skills/vds-scripts-skill/references/platform-bootstrap.md +31 -0
- package/tools/skills/vds-scripts-skill/references/specialist-routing.md +14 -0
- package/tools/skills/vds-scripts-skill/references/validation-commands.md +15 -0
- package/tools/skills/vsaf-build/SKILL.md +32 -2
- package/tools/skills/vsaf-ship/SKILL.md +41 -10
- package/tools/skills/vsaf-test/SKILL.md +8 -0
- package/tools/vds-scripts/.mcp.json +11 -0
- package/tools/vds-scripts/.secrets.baseline +133 -0
- package/tools/vds-scripts/AGENTS.md +152 -0
- package/tools/vds-scripts/CLAUDE.md +101 -0
- package/tools/vds-scripts/CLI_COMMAND_OPTIMIZATION.md +156 -0
- package/tools/vds-scripts/PACKAGE_P125B_IMPLEMENTATION_SUMMARY.md +131 -0
- package/tools/vds-scripts/PROJECT_COMPLETION_SUMMARY.md +45 -0
- package/tools/vds-scripts/README.md +97 -0
- package/tools/vds-scripts/bitbucket_manifest_mapping.toml +34 -0
- package/tools/vds-scripts/bitbucket_orchestrator/ARCHITECTURE_ANALYSIS.md +258 -0
- package/tools/vds-scripts/bitbucket_orchestrator/BITBUCKET_API_PRACTICES.md +393 -0
- package/tools/vds-scripts/bitbucket_orchestrator/EVALUATION_REPORT.md +61 -0
- package/tools/vds-scripts/bitbucket_orchestrator/FEATURES.md +908 -0
- package/tools/vds-scripts/bitbucket_orchestrator/README.md +687 -0
- package/tools/vds-scripts/bitbucket_orchestrator/pyproject.toml +40 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/__init__.py +20 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/async_client.py +657 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/cli.py +2108 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/client.py +2534 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/config.py +171 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/errors.py +67 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/factory.py +185 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/protocols.py +244 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/__init__.py +8 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/conftest.py +65 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_advanced_search.py +151 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_async_client.py +546 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_branch_permissions.py +145 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_cli.py +115 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client.py +157 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_branch_conditions.py +79 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_code_advanced.py +163 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_code_file.py +32 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_deployment_environments.py +194 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_issues.py +164 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_pipelines_advanced.py +179 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_pr_blockers.py +119 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_repository_variables.py +156 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code.py +98 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code_advanced.py +282 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code_insights.py +335 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_conditions.py +147 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_config.py +131 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_deployment_env.py +352 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_factory.py +371 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_fork_operations.py +204 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_issue_cli.py +261 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_pipeline_advanced.py +270 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_pr_blocker.py +204 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_protocols.py +334 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_repo_settings.py +343 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_repo_variables.py +270 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_webhooks.py +189 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_workspace.py +233 -0
- package/tools/vds-scripts/bitbucket_orchestrator/uv.lock +742 -0
- package/tools/vds-scripts/confluence_orchestrator/Dockerfile +19 -0
- package/tools/vds-scripts/confluence_orchestrator/README.md +412 -0
- package/tools/vds-scripts/confluence_orchestrator/SYNC_SCRIPTS.md +127 -0
- package/tools/vds-scripts/confluence_orchestrator/SYNC_STANDARDIZATION.md +108 -0
- package/tools/vds-scripts/confluence_orchestrator/pyproject.toml +48 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/__init__.py +20 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/cli.py +2532 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/config.py +175 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/content.py +290 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/content_v2.py +94 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/crawl_tree.py +1835 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/errors.py +80 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/eventing.py +109 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/http.py +1114 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/orchestration.py +165 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/reporting.py +78 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/tree.py +121 -0
- package/tools/vds-scripts/confluence_orchestrator/sync_pdfs_from_markdown.py +213 -0
- package/tools/vds-scripts/confluence_orchestrator/sync_pdfs_to_confluence.py +305 -0
- package/tools/vds-scripts/confluence_orchestrator/sync_png_attachments.py +305 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/__init__.py +0 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/conftest.py +8 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_advanced_content.py +224 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_advanced_search.py +188 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_cache_management.py +247 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_cli.py +499 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_config.py +83 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_content.py +186 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_content_flags.py +27 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_crawl_tree.py +2250 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_draft_management.py +223 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing.py +71 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_chaos.py +37 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_rate_limit.py +44 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_timeout.py +49 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_export.py +230 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_history.py +204 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_http.py +117 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_orchestration.py +91 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_reporting.py +24 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_search_cql.py +34 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_space_management.py +237 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_space_permissions.py +332 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_user_group_management.py +388 -0
- package/tools/vds-scripts/confluence_orchestrator/uv.lock +1023 -0
- package/tools/vds-scripts/git_orchestrator/ENHANCEMENT_SUMMARY.md +119 -0
- package/tools/vds-scripts/git_orchestrator/README.md +280 -0
- package/tools/vds-scripts/git_orchestrator/VERIFICATION_REPORT.md +152 -0
- package/tools/vds-scripts/git_orchestrator/pyproject.toml +35 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/__init__.py +7 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/__main__.py +4 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/cli.py +847 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/logging_config.py +63 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/manifest.py +129 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/orchestrator.py +819 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/reporting.py +53 -0
- package/tools/vds-scripts/git_orchestrator/tests/__init__.py +0 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_cli_settings.py +21 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_integration.py +74 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_manifest.py +79 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_orchestrator.py +204 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_public_api.py +236 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_resilience.py +345 -0
- package/tools/vds-scripts/git_orchestrator/uv.lock +271 -0
- package/tools/vds-scripts/jira_orchestrator/README.md +770 -0
- package/tools/vds-scripts/jira_orchestrator/pyproject.toml +39 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/__init__.py +1 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/adapter.py +1320 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/cli.py +2271 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/config.py +138 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/errors.py +67 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/reporting.py +65 -0
- package/tools/vds-scripts/jira_orchestrator/tests/__init__.py +1 -0
- package/tools/vds-scripts/jira_orchestrator/tests/conftest.py +86 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_agile_list_payloads.py +54 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_bulk_operations.py +69 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_components.py +57 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_createmeta.py +45 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_dashboard.py +117 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_issue_properties.py +54 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_permissions_compat.py +42 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_reindex.py +42 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_remote_links.py +76 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_transitions.py +91 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_user_management.py +110 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_version_management.py +133 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_watchers.py +41 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_advanced_search.py +164 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_agile.py +256 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_application_properties.py +193 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_backlog.py +91 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_bulk_operations.py +277 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_cli.py +106 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_components.py +106 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_config.py +164 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_dashboard.py +122 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_discover_fields.py +207 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_filter_management.py +333 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_issue_archiving.py +164 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_issue_links.py +257 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_issue_properties.py +171 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_link_types.py +314 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_parse_set.py +37 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_permissions.py +273 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_reindex.py +81 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_remote_links.py +254 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_security_schemes.py +170 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_transitions_changelog.py +114 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_user_management.py +226 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_version_management.py +339 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_watchers.py +101 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_worklog.py +223 -0
- package/tools/vds-scripts/jira_orchestrator/uv.lock +738 -0
- package/tools/vds-scripts/mcp_server/Dockerfile +34 -0
- package/tools/vds-scripts/mcp_server/README.md +140 -0
- package/tools/vds-scripts/mcp_server/pyproject.toml +42 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/__init__.py +4 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/config.py +36 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/server.py +66 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/__init__.py +14 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/bitbucket_tools.py +47 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/confluence_tools.py +59 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/git_tools.py +71 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/jira_tools.py +63 -0
- package/tools/vds-scripts/mcp_server/tests/__init__.py +2 -0
- package/tools/vds-scripts/mcp_server/tests/conftest.py +29 -0
- package/tools/vds-scripts/mcp_server/tests/unit/__init__.py +2 -0
- package/tools/vds-scripts/mcp_server/tests/unit/test_bitbucket_tools.py +25 -0
- package/tools/vds-scripts/mcp_server/tests/unit/test_confluence_tools.py +25 -0
- package/tools/vds-scripts/mcp_server/tests/unit/test_git_tools.py +32 -0
- package/tools/vds-scripts/mcp_server/tests/unit/test_jira_tools.py +32 -0
- package/tools/vds-scripts/mcp_server/tests/verification/__init__.py +2 -0
- package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_confluence_tools.py +40 -0
- package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_jira_tools.py +37 -0
- package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_tool_registration.py +47 -0
- package/tools/vds-scripts/mcp_server/uv.lock +1032 -0
- package/tools/vds-scripts/mypy.ini +5 -0
- package/tools/vds-scripts/pyproject.toml +29 -0
- package/tools/vds-scripts/repo-manifest.yaml +273 -0
- package/tools/vds-scripts/repo-manifest.yaml.example +25 -0
- package/tools/vds-scripts/scripts/BRD-Validation-API.postman_collection.json +706 -0
- package/tools/vds-scripts/scripts/BRD-Validation-README.md +308 -0
- package/tools/vds-scripts/scripts/README.md +162 -0
- package/tools/vds-scripts/scripts/bootstrap_uv.sh +30 -0
- package/tools/vds-scripts/scripts/brd-validation-environment.json +51 -0
- package/tools/vds-scripts/scripts/brd-validation-test-results.json +13023 -0
- package/tools/vds-scripts/scripts/brd_coverage_report.json +276 -0
- package/tools/vds-scripts/scripts/create_memory_session.py +35 -0
- package/tools/vds-scripts/scripts/deployment/load_docker_images_offline.sh +90 -0
- package/tools/vds-scripts/scripts/final_completion_report.md +139 -0
- package/tools/vds-scripts/scripts/folder_structure_report.json +321 -0
- package/tools/vds-scripts/scripts/generate_completion_report.py +125 -0
- package/tools/vds-scripts/scripts/generate_intellij_modules.py +150 -0
- package/tools/vds-scripts/scripts/link_integrity_report.json +807 -0
- package/tools/vds-scripts/scripts/move_audit_artifact_pages.py +255 -0
- package/tools/vds-scripts/scripts/move_audit_artifact_pages_rest.py +165 -0
- package/tools/vds-scripts/scripts/move_wrong_dept_pages.py +216 -0
- package/tools/vds-scripts/scripts/save_intellij_memories.py +120 -0
- package/tools/vds-scripts/scripts/save_memories_to_vds_ai.py +83 -0
- package/tools/vds-scripts/scripts/save_memories_vds_style.py +129 -0
- package/tools/vds-scripts/scripts/search_intellij_memories.py +50 -0
- package/tools/vds-scripts/scripts/setup_intellij_workspace.py +65 -0
- package/tools/vds-scripts/scripts/target-state-automation/README.md +89 -0
- package/tools/vds-scripts/scripts/target-state-automation/confluence_sync_coordinator.sh +27 -0
- package/tools/vds-scripts/scripts/target-state-automation/coordination.sh +114 -0
- package/tools/vds-scripts/scripts/target-state-automation/diagram_coordinator.sh +25 -0
- package/tools/vds-scripts/scripts/target-state-automation/docs_root.sh +22 -0
- package/tools/vds-scripts/scripts/target-state-automation/generate_diagrams.sh +22 -0
- package/tools/vds-scripts/scripts/target-state-automation/markdown_coordinator.sh +25 -0
- package/tools/vds-scripts/scripts/target-state-automation/progress_dashboard.sh +17 -0
- package/tools/vds-scripts/scripts/target-state-automation/schema_coordinator.sh +25 -0
- package/tools/vds-scripts/scripts/target-state-automation/sync_confluence.sh +30 -0
- package/tools/vds-scripts/scripts/target-state-automation/update_dependencies.sh +19 -0
- package/tools/vds-scripts/scripts/target-state-automation/validate_links.sh +86 -0
- package/tools/vds-scripts/scripts/target-state-automation/validate_markdown.sh +52 -0
- package/tools/vds-scripts/scripts/target-state-automation/validate_schemas.sh +26 -0
- package/tools/vds-scripts/scripts/target-state-automation/validate_structure.sh +98 -0
- package/tools/vds-scripts/scripts/update_modules_xml.py +190 -0
- package/tools/vds-scripts/scripts/uv-workspace-alignment-verification-2026-03-25.md +128 -0
- package/tools/vds-scripts/scripts/validate_brd_coverage.py +179 -0
- package/tools/vds-scripts/scripts/validate_folder_structure.py +240 -0
- package/tools/vds-scripts/scripts/validate_link_integrity.py +272 -0
- package/tools/vds-scripts/scripts/vds_sh_helpers.sh +180 -0
- package/tools/vds-scripts/scripts/verification/phase2_portable_paths_ubuntu_docker.sh +26 -0
- package/tools/vds-scripts/scripts/worktree_uv.sh +48 -0
- package/tools/vds-scripts/uv.lock +8 -0
- package/tools/vds-scripts/vds_cli/README.md +126 -0
- package/tools/vds-scripts/vds_cli/VERIFICATION_REPORT.md +41 -0
- package/tools/vds-scripts/vds_cli/pyproject.toml +38 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/__init__.py +3 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/cli.py +173 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/docs_sync.py +1203 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/env.py +41 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/google_sheets_orchestrator/__init__.py +3 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/google_sheets_orchestrator/google_sheets_orchestrator.py +198 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/router.py +93 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/sync_api.py +647 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/sync_service.py +266 -0
- package/tools/vds-scripts/vds_cli/tests/__init__.py +2 -0
- package/tools/vds-scripts/vds_cli/tests/conftest.py +49 -0
- package/tools/vds-scripts/vds_cli/tests/unit/__init__.py +2 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_cli.py +143 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_docs_sync.py +422 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_env.py +51 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_router.py +72 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_sync_api.py +357 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_sync_service.py +160 -0
- package/tools/vds-scripts/vds_cli/tests/verification/__init__.py +2 -0
- package/tools/vds-scripts/vds_cli/tests/verification/test_bitbucket_real.py +33 -0
- package/tools/vds-scripts/vds_cli/tests/verification/test_confluence_real.py +35 -0
- package/tools/vds-scripts/vds_cli/tests/verification/test_jira_real.py +41 -0
- package/tools/vds-scripts/vds_cli/uv.lock +524 -0
- package/tools/vds-scripts/vds_cli_common/README.md +190 -0
- package/tools/vds-scripts/vds_cli_common/pyproject.toml +92 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/__init__.py +34 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/completers.py +139 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/context.py +201 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/env.py +119 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/errors.py +318 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/output.py +284 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/paths.py +78 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/testing.py +213 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/version.py +85 -0
- package/tools/vds-scripts/vds_cli_common/tests/__init__.py +1 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_completers.py +148 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_context.py +192 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_env.py +102 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_errors.py +186 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_output.py +229 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_paths.py +61 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_testing.py +138 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_version.py +64 -0
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
"""AGENTS-first sync engine for AGENTS plus legacy mirrored instruction files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from difflib import SequenceMatcher
|
|
14
|
+
from errno import EACCES, EBUSY, EDEADLK, EPERM
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Protocol, TypeVar
|
|
17
|
+
|
|
18
|
+
from .sync_api import (
|
|
19
|
+
ConfluencePropertySyncApiClient,
|
|
20
|
+
SyncApiClient,
|
|
21
|
+
SyncApiDocument,
|
|
22
|
+
SyncApiError,
|
|
23
|
+
SyncApiRevisionConflict,
|
|
24
|
+
SyncApiTransientError,
|
|
25
|
+
VdsAiMemorySyncApiClient,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
WatchBackend = str
|
|
29
|
+
_T = TypeVar("_T")
|
|
30
|
+
|
|
31
|
+
TARGET_FILES: tuple[str, str, str] = (
|
|
32
|
+
"AGENTS.md",
|
|
33
|
+
"CLAUDE.md",
|
|
34
|
+
".github/copilot-instructions.md",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SyncError(RuntimeError):
|
|
39
|
+
"""Base error for instruction sync failures."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RevisionConflictError(SyncError):
|
|
43
|
+
"""Raised when central state revision does not match expected revision."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class CentralDocument:
|
|
48
|
+
"""Canonical central-state document."""
|
|
49
|
+
|
|
50
|
+
content: str
|
|
51
|
+
revision: str
|
|
52
|
+
content_hash: str
|
|
53
|
+
source_file: str
|
|
54
|
+
updated_at: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CentralStateBackend(Protocol):
|
|
58
|
+
"""Central-state adapter interface."""
|
|
59
|
+
|
|
60
|
+
def pull(self) -> CentralDocument | None:
|
|
61
|
+
"""Return current canonical document or None when not initialized."""
|
|
62
|
+
|
|
63
|
+
def push_if_match(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
content: str,
|
|
67
|
+
source_file: str,
|
|
68
|
+
expected_revision: str | None,
|
|
69
|
+
) -> CentralDocument:
|
|
70
|
+
"""Update canonical document using optimistic concurrency semantics."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class LocalJsonCentralStateBackend:
|
|
75
|
+
"""Local JSON-backed central state with If-Match style revision checks."""
|
|
76
|
+
|
|
77
|
+
state_path: Path
|
|
78
|
+
|
|
79
|
+
def pull(self) -> CentralDocument | None:
|
|
80
|
+
if not self.state_path.exists():
|
|
81
|
+
return None
|
|
82
|
+
payload = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
83
|
+
return CentralDocument(
|
|
84
|
+
content=payload["content"],
|
|
85
|
+
revision=str(payload["revision"]),
|
|
86
|
+
content_hash=payload["content_hash"],
|
|
87
|
+
source_file=payload["source_file"],
|
|
88
|
+
updated_at=payload["updated_at"],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def push_if_match(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
content: str,
|
|
95
|
+
source_file: str,
|
|
96
|
+
expected_revision: str | None,
|
|
97
|
+
) -> CentralDocument:
|
|
98
|
+
current = self.pull()
|
|
99
|
+
if expected_revision is None:
|
|
100
|
+
if current is not None:
|
|
101
|
+
raise RevisionConflictError(
|
|
102
|
+
f"Expected empty central state but found revision {current.revision}."
|
|
103
|
+
)
|
|
104
|
+
elif current is None or current.revision != expected_revision:
|
|
105
|
+
actual = current.revision if current else "<missing>"
|
|
106
|
+
raise RevisionConflictError(
|
|
107
|
+
f"Central revision mismatch (expected={expected_revision}, actual={actual})."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
revision = "1" if current is None else str(int(current.revision) + 1)
|
|
111
|
+
now = datetime.now(UTC).isoformat()
|
|
112
|
+
payload = {
|
|
113
|
+
"content": content,
|
|
114
|
+
"revision": revision,
|
|
115
|
+
"content_hash": _sha256(content),
|
|
116
|
+
"source_file": source_file,
|
|
117
|
+
"updated_at": now,
|
|
118
|
+
}
|
|
119
|
+
_write_json_atomic(self.state_path, payload)
|
|
120
|
+
return CentralDocument(
|
|
121
|
+
content=payload["content"],
|
|
122
|
+
revision=payload["revision"],
|
|
123
|
+
content_hash=payload["content_hash"],
|
|
124
|
+
source_file=payload["source_file"],
|
|
125
|
+
updated_at=payload["updated_at"],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class HttpCentralStateBackend:
|
|
131
|
+
"""HTTP-backed central state with optimistic concurrency semantics."""
|
|
132
|
+
|
|
133
|
+
client: SyncApiClient
|
|
134
|
+
retry_attempts: int = 3
|
|
135
|
+
retry_base_delay_seconds: float = 0.2
|
|
136
|
+
retry_max_delay_seconds: float = 1.0
|
|
137
|
+
|
|
138
|
+
def pull(self) -> CentralDocument | None:
|
|
139
|
+
try:
|
|
140
|
+
document = self._retry_transient_errors("pull", self.client.pull_document)
|
|
141
|
+
except SyncApiError as exc:
|
|
142
|
+
raise SyncError(f"Failed pulling central state via HTTP: {exc}") from exc
|
|
143
|
+
if document is None:
|
|
144
|
+
return None
|
|
145
|
+
return _as_central_document(document)
|
|
146
|
+
|
|
147
|
+
def push_if_match(
|
|
148
|
+
self,
|
|
149
|
+
*,
|
|
150
|
+
content: str,
|
|
151
|
+
source_file: str,
|
|
152
|
+
expected_revision: str | None,
|
|
153
|
+
) -> CentralDocument:
|
|
154
|
+
try:
|
|
155
|
+
document = self._retry_transient_errors(
|
|
156
|
+
"push",
|
|
157
|
+
lambda: self.client.push_document_if_match(
|
|
158
|
+
content=content,
|
|
159
|
+
source_file=source_file,
|
|
160
|
+
expected_revision=expected_revision,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
except SyncApiRevisionConflict as exc:
|
|
164
|
+
raise RevisionConflictError(str(exc)) from exc
|
|
165
|
+
except SyncApiError as exc:
|
|
166
|
+
raise SyncError(f"Failed pushing central state via HTTP: {exc}") from exc
|
|
167
|
+
return _as_central_document(document)
|
|
168
|
+
|
|
169
|
+
def _retry_transient_errors(self, operation: str, request_fn: Callable[[], _T]) -> _T:
|
|
170
|
+
attempts = max(self.retry_attempts, 1)
|
|
171
|
+
for attempt in range(1, attempts + 1):
|
|
172
|
+
try:
|
|
173
|
+
return request_fn()
|
|
174
|
+
except SyncApiTransientError:
|
|
175
|
+
if attempt >= attempts:
|
|
176
|
+
raise
|
|
177
|
+
delay = min(
|
|
178
|
+
self.retry_base_delay_seconds * (2 ** (attempt - 1)),
|
|
179
|
+
self.retry_max_delay_seconds,
|
|
180
|
+
)
|
|
181
|
+
time.sleep(delay)
|
|
182
|
+
except SyncApiRevisionConflict:
|
|
183
|
+
raise
|
|
184
|
+
raise SyncError(f"Unexpected retry state for operation '{operation}'.")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class SyncLedger:
|
|
189
|
+
"""Local sync ledger for incremental conflict detection."""
|
|
190
|
+
|
|
191
|
+
last_revision: str | None = None
|
|
192
|
+
last_source_file: str | None = None
|
|
193
|
+
file_hashes: dict[str, str] = field(default_factory=dict)
|
|
194
|
+
synced_hash: str | None = None
|
|
195
|
+
synced_content: str | None = None
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def load(cls, path: Path) -> SyncLedger:
|
|
199
|
+
if not path.exists():
|
|
200
|
+
return cls()
|
|
201
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
202
|
+
return cls(
|
|
203
|
+
last_revision=payload.get("last_revision"),
|
|
204
|
+
last_source_file=payload.get("last_source_file"),
|
|
205
|
+
file_hashes=dict(payload.get("file_hashes", {})),
|
|
206
|
+
synced_hash=payload.get("synced_hash"),
|
|
207
|
+
synced_content=payload.get("synced_content"),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def save(self, path: Path) -> None:
|
|
211
|
+
payload = {
|
|
212
|
+
"last_revision": self.last_revision,
|
|
213
|
+
"last_source_file": self.last_source_file,
|
|
214
|
+
"file_hashes": self.file_hashes,
|
|
215
|
+
"synced_hash": self.synced_hash,
|
|
216
|
+
"synced_content": self.synced_content,
|
|
217
|
+
}
|
|
218
|
+
_write_json_atomic(path, payload)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True)
|
|
222
|
+
class SyncConfig:
|
|
223
|
+
"""Runtime configuration for watch-agents sync."""
|
|
224
|
+
|
|
225
|
+
repo_root: Path
|
|
226
|
+
target_files: tuple[str, str, str] = TARGET_FILES
|
|
227
|
+
workspace_dirname: str = ".vds-sync"
|
|
228
|
+
reconcile_interval_seconds: int = 300
|
|
229
|
+
debounce_ms: int = 500
|
|
230
|
+
watch_backend: WatchBackend = "auto"
|
|
231
|
+
central_backend: str = "local"
|
|
232
|
+
central_contract: str = "generic"
|
|
233
|
+
central_state_url: str | None = None
|
|
234
|
+
central_timeout_seconds: int = 10
|
|
235
|
+
central_auth_token: str | None = None
|
|
236
|
+
central_memory_id: str | None = None
|
|
237
|
+
central_user_id: str = "vds-sync-agent"
|
|
238
|
+
central_session_id: str = "vds-sync-session"
|
|
239
|
+
central_confluence_server: str = "internal"
|
|
240
|
+
central_confluence_page_id: str | None = None
|
|
241
|
+
central_confluence_property_key: str = "vds_sync_instructions"
|
|
242
|
+
dry_run: bool = False
|
|
243
|
+
log_json: bool = True
|
|
244
|
+
lock_stale_seconds: int = 900
|
|
245
|
+
lock_timeout_seconds: int = 5
|
|
246
|
+
conflict_dir: Path | None = None
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def sync_dir(self) -> Path:
|
|
250
|
+
return self.repo_root / self.workspace_dirname
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def central_state_path(self) -> Path:
|
|
254
|
+
return self.sync_dir / "instructions-central-state.json"
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def ledger_path(self) -> Path:
|
|
258
|
+
return self.sync_dir / "watch-agents-ledger.json"
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def lock_path(self) -> Path:
|
|
262
|
+
return self.sync_dir / "watch-agents.lock"
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def log_path(self) -> Path:
|
|
266
|
+
return self.sync_dir / "watch-agents.log.jsonl"
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def resolved_conflict_dir(self) -> Path:
|
|
270
|
+
return self.conflict_dir if self.conflict_dir is not None else self.sync_dir / "conflicts"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True)
|
|
274
|
+
class FileSnapshot:
|
|
275
|
+
"""Snapshot of one target file."""
|
|
276
|
+
|
|
277
|
+
relpath: str
|
|
278
|
+
abspath: Path
|
|
279
|
+
content: str
|
|
280
|
+
content_hash: str
|
|
281
|
+
mtime: float
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True)
|
|
285
|
+
class LineEdit:
|
|
286
|
+
"""A line-range replacement edit for conservative three-way merge."""
|
|
287
|
+
|
|
288
|
+
start: int
|
|
289
|
+
end: int
|
|
290
|
+
replacement: tuple[str, ...]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class SyncFileLock:
|
|
294
|
+
"""Simple cross-platform lock-file with stale lock eviction."""
|
|
295
|
+
|
|
296
|
+
def __init__(self, path: Path, *, stale_seconds: int, timeout_seconds: int) -> None:
|
|
297
|
+
self.path = path
|
|
298
|
+
self.stale_seconds = stale_seconds
|
|
299
|
+
self.timeout_seconds = timeout_seconds
|
|
300
|
+
|
|
301
|
+
def __enter__(self) -> SyncFileLock:
|
|
302
|
+
deadline = time.time() + self.timeout_seconds
|
|
303
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
|
|
305
|
+
while True:
|
|
306
|
+
try:
|
|
307
|
+
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
308
|
+
try:
|
|
309
|
+
payload = {
|
|
310
|
+
"pid": os.getpid(),
|
|
311
|
+
"created_at": time.time(),
|
|
312
|
+
}
|
|
313
|
+
os.write(fd, json.dumps(payload).encode("utf-8"))
|
|
314
|
+
finally:
|
|
315
|
+
os.close(fd)
|
|
316
|
+
return self
|
|
317
|
+
except FileExistsError:
|
|
318
|
+
if self._is_stale():
|
|
319
|
+
self.path.unlink(missing_ok=True)
|
|
320
|
+
continue
|
|
321
|
+
if time.time() > deadline:
|
|
322
|
+
raise SyncError(f"Could not acquire lock: {self.path}") from None
|
|
323
|
+
time.sleep(0.1)
|
|
324
|
+
|
|
325
|
+
def __exit__(self, *_args: object) -> None:
|
|
326
|
+
self.path.unlink(missing_ok=True)
|
|
327
|
+
|
|
328
|
+
def _is_stale(self) -> bool:
|
|
329
|
+
try:
|
|
330
|
+
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
|
331
|
+
created_at = float(payload.get("created_at", 0))
|
|
332
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
333
|
+
return True
|
|
334
|
+
return (time.time() - created_at) > self.stale_seconds
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class DocsSyncEngine:
|
|
338
|
+
"""Core multi-writer reconciliation engine."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, *, config: SyncConfig, backend: CentralStateBackend | None = None) -> None:
|
|
341
|
+
self.config = config
|
|
342
|
+
self.backend = backend or _build_backend(config)
|
|
343
|
+
self.ledger = SyncLedger.load(config.ledger_path)
|
|
344
|
+
|
|
345
|
+
def run_once(self, *, reason: str) -> str:
|
|
346
|
+
snapshots = self._collect_snapshots()
|
|
347
|
+
central = self.backend.pull()
|
|
348
|
+
|
|
349
|
+
if self._needs_startup_strategy(central):
|
|
350
|
+
return self._startup_reconcile(snapshots=snapshots, central=central, reason=reason)
|
|
351
|
+
|
|
352
|
+
return self._steady_state_reconcile(snapshots=snapshots, central=central, reason=reason)
|
|
353
|
+
|
|
354
|
+
def watch(self, *, once: bool = False) -> None:
|
|
355
|
+
with SyncFileLock(
|
|
356
|
+
self.config.lock_path,
|
|
357
|
+
stale_seconds=self.config.lock_stale_seconds,
|
|
358
|
+
timeout_seconds=self.config.lock_timeout_seconds,
|
|
359
|
+
):
|
|
360
|
+
self.run_once(reason="startup")
|
|
361
|
+
if once:
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
watch_backend = self._resolve_watch_backend()
|
|
365
|
+
self._log("watch-backend-selected", backend=watch_backend)
|
|
366
|
+
if watch_backend == "watchfiles":
|
|
367
|
+
self._watch_loop_watchfiles()
|
|
368
|
+
return
|
|
369
|
+
self._watch_loop_polling()
|
|
370
|
+
|
|
371
|
+
def _watch_loop_polling(self) -> None:
|
|
372
|
+
last_periodic = time.monotonic()
|
|
373
|
+
previous_hashes = self._snapshot_hash_map(self._collect_snapshots())
|
|
374
|
+
|
|
375
|
+
while True:
|
|
376
|
+
time.sleep(max(self.config.debounce_ms / 1000.0, 0.2))
|
|
377
|
+
|
|
378
|
+
current = self._collect_snapshots()
|
|
379
|
+
current_hashes = self._snapshot_hash_map(current)
|
|
380
|
+
if current_hashes != previous_hashes:
|
|
381
|
+
self.run_once(reason="event")
|
|
382
|
+
previous_hashes = self._snapshot_hash_map(self._collect_snapshots())
|
|
383
|
+
|
|
384
|
+
now = time.monotonic()
|
|
385
|
+
if now - last_periodic >= self.config.reconcile_interval_seconds:
|
|
386
|
+
self.run_once(reason="periodic")
|
|
387
|
+
previous_hashes = self._snapshot_hash_map(self._collect_snapshots())
|
|
388
|
+
last_periodic = now
|
|
389
|
+
|
|
390
|
+
def _watch_loop_watchfiles(self) -> None:
|
|
391
|
+
watch = _load_watchfiles_watch()
|
|
392
|
+
if watch is None:
|
|
393
|
+
raise SyncError(
|
|
394
|
+
"watch_backend='watchfiles' requested but 'watchfiles' package is not installed."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
last_periodic = time.monotonic()
|
|
398
|
+
target_files = {(self.config.repo_root / relpath).resolve() for relpath in self.config.target_files}
|
|
399
|
+
rust_timeout_ms = max(200, min(self.config.debounce_ms, 2000))
|
|
400
|
+
|
|
401
|
+
for changes in watch(
|
|
402
|
+
self.config.repo_root,
|
|
403
|
+
recursive=True,
|
|
404
|
+
debounce=self.config.debounce_ms,
|
|
405
|
+
yield_on_timeout=True,
|
|
406
|
+
rust_timeout=rust_timeout_ms,
|
|
407
|
+
):
|
|
408
|
+
has_target_change = False
|
|
409
|
+
for _change, changed_path in changes:
|
|
410
|
+
try:
|
|
411
|
+
resolved = Path(changed_path).resolve()
|
|
412
|
+
except OSError:
|
|
413
|
+
continue
|
|
414
|
+
if resolved in target_files:
|
|
415
|
+
has_target_change = True
|
|
416
|
+
break
|
|
417
|
+
|
|
418
|
+
if has_target_change:
|
|
419
|
+
self.run_once(reason="event")
|
|
420
|
+
|
|
421
|
+
now = time.monotonic()
|
|
422
|
+
if now - last_periodic >= self.config.reconcile_interval_seconds:
|
|
423
|
+
self.run_once(reason="periodic")
|
|
424
|
+
last_periodic = now
|
|
425
|
+
|
|
426
|
+
def _resolve_watch_backend(self) -> WatchBackend:
|
|
427
|
+
configured = self.config.watch_backend.lower().strip()
|
|
428
|
+
if configured not in {"auto", "watchfiles", "polling"}:
|
|
429
|
+
raise SyncError(
|
|
430
|
+
f"Invalid watch backend '{self.config.watch_backend}'. Expected: auto, watchfiles, polling."
|
|
431
|
+
)
|
|
432
|
+
if configured == "polling":
|
|
433
|
+
return "polling"
|
|
434
|
+
if configured == "watchfiles":
|
|
435
|
+
if _load_watchfiles_watch() is None:
|
|
436
|
+
raise SyncError(
|
|
437
|
+
"watch_backend='watchfiles' requested but 'watchfiles' package is not installed."
|
|
438
|
+
)
|
|
439
|
+
return "watchfiles"
|
|
440
|
+
if _load_watchfiles_watch() is not None:
|
|
441
|
+
return "watchfiles"
|
|
442
|
+
return "polling"
|
|
443
|
+
|
|
444
|
+
def _startup_reconcile(
|
|
445
|
+
self,
|
|
446
|
+
*,
|
|
447
|
+
snapshots: dict[str, FileSnapshot],
|
|
448
|
+
central: CentralDocument | None,
|
|
449
|
+
reason: str,
|
|
450
|
+
) -> str:
|
|
451
|
+
local_hashes = {rel: snap.content_hash for rel, snap in snapshots.items()}
|
|
452
|
+
unique_hashes = set(local_hashes.values())
|
|
453
|
+
|
|
454
|
+
if central is None:
|
|
455
|
+
source = self._select_local_source_without_baseline(snapshots)
|
|
456
|
+
if source is None:
|
|
457
|
+
artifact = self._write_conflict_artifact(
|
|
458
|
+
reason="startup-local-conflict",
|
|
459
|
+
snapshots=snapshots,
|
|
460
|
+
central=central,
|
|
461
|
+
)
|
|
462
|
+
self._log(
|
|
463
|
+
"conflict",
|
|
464
|
+
reason=reason,
|
|
465
|
+
artifact=str(artifact),
|
|
466
|
+
conflict_state="artifact-written",
|
|
467
|
+
write_outcomes={},
|
|
468
|
+
)
|
|
469
|
+
return "conflict"
|
|
470
|
+
canonical = self.backend.push_if_match(
|
|
471
|
+
content=snapshots[source].content,
|
|
472
|
+
source_file=source,
|
|
473
|
+
expected_revision=None,
|
|
474
|
+
)
|
|
475
|
+
write_outcomes = self._fan_out(
|
|
476
|
+
canonical.content,
|
|
477
|
+
canonical_hash=canonical.content_hash,
|
|
478
|
+
)
|
|
479
|
+
self._update_ledger(canonical=canonical)
|
|
480
|
+
self._log(
|
|
481
|
+
"sync",
|
|
482
|
+
reason=reason,
|
|
483
|
+
source_file=source,
|
|
484
|
+
source_hash=snapshots[source].content_hash,
|
|
485
|
+
revision=canonical.revision,
|
|
486
|
+
conflict_state="none",
|
|
487
|
+
write_outcomes=write_outcomes,
|
|
488
|
+
)
|
|
489
|
+
return "synced"
|
|
490
|
+
|
|
491
|
+
if len(unique_hashes) == 1:
|
|
492
|
+
only_hash = next(iter(unique_hashes))
|
|
493
|
+
if only_hash == central.content_hash:
|
|
494
|
+
self._update_ledger(canonical=central)
|
|
495
|
+
self._log(
|
|
496
|
+
"noop",
|
|
497
|
+
reason=reason,
|
|
498
|
+
source_file=central.source_file,
|
|
499
|
+
source_hash=central.content_hash,
|
|
500
|
+
revision=central.revision,
|
|
501
|
+
conflict_state="none",
|
|
502
|
+
write_outcomes={},
|
|
503
|
+
)
|
|
504
|
+
return "noop"
|
|
505
|
+
write_outcomes = self._fan_out(
|
|
506
|
+
central.content,
|
|
507
|
+
canonical_hash=central.content_hash,
|
|
508
|
+
)
|
|
509
|
+
self._update_ledger(canonical=central)
|
|
510
|
+
self._log(
|
|
511
|
+
"fanout",
|
|
512
|
+
reason=reason,
|
|
513
|
+
source_file=central.source_file,
|
|
514
|
+
source_hash=central.content_hash,
|
|
515
|
+
revision=central.revision,
|
|
516
|
+
conflict_state="none",
|
|
517
|
+
write_outcomes=write_outcomes,
|
|
518
|
+
)
|
|
519
|
+
return "fanout"
|
|
520
|
+
|
|
521
|
+
source = self._select_single_modified_candidate(snapshots, baseline_hash=central.content_hash)
|
|
522
|
+
if source is None:
|
|
523
|
+
artifact = self._write_conflict_artifact(
|
|
524
|
+
reason="startup-divergent-local-state",
|
|
525
|
+
snapshots=snapshots,
|
|
526
|
+
central=central,
|
|
527
|
+
)
|
|
528
|
+
self._log(
|
|
529
|
+
"conflict",
|
|
530
|
+
reason=reason,
|
|
531
|
+
artifact=str(artifact),
|
|
532
|
+
revision=central.revision,
|
|
533
|
+
conflict_state="artifact-written",
|
|
534
|
+
write_outcomes={},
|
|
535
|
+
)
|
|
536
|
+
return "conflict"
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
canonical = self.backend.push_if_match(
|
|
540
|
+
content=snapshots[source].content,
|
|
541
|
+
source_file=source,
|
|
542
|
+
expected_revision=central.revision,
|
|
543
|
+
)
|
|
544
|
+
except RevisionConflictError:
|
|
545
|
+
retried = self._retry_with_safe_merge(
|
|
546
|
+
source=source,
|
|
547
|
+
local_content=snapshots[source].content,
|
|
548
|
+
reason=f"{reason}:startup-central-revision-conflict",
|
|
549
|
+
)
|
|
550
|
+
if retried is not None:
|
|
551
|
+
write_outcomes = self._fan_out(
|
|
552
|
+
retried.content,
|
|
553
|
+
canonical_hash=retried.content_hash,
|
|
554
|
+
)
|
|
555
|
+
self._update_ledger(canonical=retried)
|
|
556
|
+
self._log(
|
|
557
|
+
"sync-merge-retry",
|
|
558
|
+
reason=reason,
|
|
559
|
+
source_file=source,
|
|
560
|
+
source_hash=snapshots[source].content_hash,
|
|
561
|
+
revision=retried.revision,
|
|
562
|
+
conflict_state="merge-applied",
|
|
563
|
+
write_outcomes=write_outcomes,
|
|
564
|
+
)
|
|
565
|
+
return "synced"
|
|
566
|
+
artifact = self._write_conflict_artifact(
|
|
567
|
+
reason="startup-central-revision-conflict",
|
|
568
|
+
snapshots=snapshots,
|
|
569
|
+
central=self.backend.pull(),
|
|
570
|
+
)
|
|
571
|
+
self._log(
|
|
572
|
+
"conflict",
|
|
573
|
+
reason=reason,
|
|
574
|
+
artifact=str(artifact),
|
|
575
|
+
source_file=source,
|
|
576
|
+
source_hash=snapshots[source].content_hash,
|
|
577
|
+
conflict_state="artifact-written",
|
|
578
|
+
write_outcomes={},
|
|
579
|
+
)
|
|
580
|
+
return "conflict"
|
|
581
|
+
|
|
582
|
+
write_outcomes = self._fan_out(
|
|
583
|
+
canonical.content,
|
|
584
|
+
canonical_hash=canonical.content_hash,
|
|
585
|
+
)
|
|
586
|
+
self._update_ledger(canonical=canonical)
|
|
587
|
+
self._log(
|
|
588
|
+
"sync",
|
|
589
|
+
reason=reason,
|
|
590
|
+
source_file=source,
|
|
591
|
+
source_hash=snapshots[source].content_hash,
|
|
592
|
+
revision=canonical.revision,
|
|
593
|
+
conflict_state="none",
|
|
594
|
+
write_outcomes=write_outcomes,
|
|
595
|
+
)
|
|
596
|
+
return "synced"
|
|
597
|
+
|
|
598
|
+
def _steady_state_reconcile(
|
|
599
|
+
self,
|
|
600
|
+
*,
|
|
601
|
+
snapshots: dict[str, FileSnapshot],
|
|
602
|
+
central: CentralDocument | None,
|
|
603
|
+
reason: str,
|
|
604
|
+
) -> str:
|
|
605
|
+
changed = [
|
|
606
|
+
rel
|
|
607
|
+
for rel, snap in snapshots.items()
|
|
608
|
+
if self.ledger.file_hashes.get(rel) != snap.content_hash
|
|
609
|
+
]
|
|
610
|
+
|
|
611
|
+
if not changed:
|
|
612
|
+
if central and any(s.content_hash != central.content_hash for s in snapshots.values()):
|
|
613
|
+
write_outcomes = self._fan_out(
|
|
614
|
+
central.content,
|
|
615
|
+
canonical_hash=central.content_hash,
|
|
616
|
+
)
|
|
617
|
+
self._update_ledger(canonical=central)
|
|
618
|
+
self._log(
|
|
619
|
+
"fanout",
|
|
620
|
+
reason=reason,
|
|
621
|
+
source_file=central.source_file,
|
|
622
|
+
source_hash=central.content_hash,
|
|
623
|
+
revision=central.revision,
|
|
624
|
+
conflict_state="none",
|
|
625
|
+
write_outcomes=write_outcomes,
|
|
626
|
+
)
|
|
627
|
+
return "fanout"
|
|
628
|
+
self._log("noop", reason=reason, conflict_state="none", write_outcomes={})
|
|
629
|
+
return "noop"
|
|
630
|
+
|
|
631
|
+
distinct_hashes = {snapshots[rel].content_hash for rel in changed}
|
|
632
|
+
if len(changed) > 1 and len(distinct_hashes) > 1:
|
|
633
|
+
artifact = self._write_conflict_artifact(
|
|
634
|
+
reason="divergent-local-edits",
|
|
635
|
+
snapshots=snapshots,
|
|
636
|
+
central=central,
|
|
637
|
+
)
|
|
638
|
+
self._log(
|
|
639
|
+
"conflict",
|
|
640
|
+
reason=reason,
|
|
641
|
+
artifact=str(artifact),
|
|
642
|
+
conflict_state="artifact-written",
|
|
643
|
+
write_outcomes={},
|
|
644
|
+
)
|
|
645
|
+
return "conflict"
|
|
646
|
+
|
|
647
|
+
source = changed[0]
|
|
648
|
+
expected_revision = self.ledger.last_revision or (central.revision if central else None)
|
|
649
|
+
if expected_revision is None:
|
|
650
|
+
expected_revision = None
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
canonical = self.backend.push_if_match(
|
|
654
|
+
content=snapshots[source].content,
|
|
655
|
+
source_file=source,
|
|
656
|
+
expected_revision=expected_revision,
|
|
657
|
+
)
|
|
658
|
+
except RevisionConflictError:
|
|
659
|
+
retried = self._retry_with_safe_merge(
|
|
660
|
+
source=source,
|
|
661
|
+
local_content=snapshots[source].content,
|
|
662
|
+
reason=f"{reason}:central-revision-mismatch",
|
|
663
|
+
)
|
|
664
|
+
if retried is not None:
|
|
665
|
+
write_outcomes = self._fan_out(
|
|
666
|
+
retried.content,
|
|
667
|
+
canonical_hash=retried.content_hash,
|
|
668
|
+
)
|
|
669
|
+
self._update_ledger(canonical=retried)
|
|
670
|
+
self._log(
|
|
671
|
+
"sync-merge-retry",
|
|
672
|
+
reason=reason,
|
|
673
|
+
source_file=source,
|
|
674
|
+
source_hash=snapshots[source].content_hash,
|
|
675
|
+
revision=retried.revision,
|
|
676
|
+
conflict_state="merge-applied",
|
|
677
|
+
write_outcomes=write_outcomes,
|
|
678
|
+
)
|
|
679
|
+
return "synced"
|
|
680
|
+
artifact = self._write_conflict_artifact(
|
|
681
|
+
reason="central-revision-mismatch",
|
|
682
|
+
snapshots=snapshots,
|
|
683
|
+
central=self.backend.pull(),
|
|
684
|
+
)
|
|
685
|
+
self._log(
|
|
686
|
+
"conflict",
|
|
687
|
+
reason=reason,
|
|
688
|
+
artifact=str(artifact),
|
|
689
|
+
source_file=source,
|
|
690
|
+
source_hash=snapshots[source].content_hash,
|
|
691
|
+
conflict_state="artifact-written",
|
|
692
|
+
write_outcomes={},
|
|
693
|
+
)
|
|
694
|
+
return "conflict"
|
|
695
|
+
|
|
696
|
+
write_outcomes = self._fan_out(
|
|
697
|
+
canonical.content,
|
|
698
|
+
canonical_hash=canonical.content_hash,
|
|
699
|
+
)
|
|
700
|
+
self._update_ledger(canonical=canonical)
|
|
701
|
+
self._log(
|
|
702
|
+
"sync",
|
|
703
|
+
reason=reason,
|
|
704
|
+
source_file=source,
|
|
705
|
+
source_hash=snapshots[source].content_hash,
|
|
706
|
+
revision=canonical.revision,
|
|
707
|
+
conflict_state="none",
|
|
708
|
+
write_outcomes=write_outcomes,
|
|
709
|
+
)
|
|
710
|
+
return "synced"
|
|
711
|
+
|
|
712
|
+
def _collect_snapshots(self) -> dict[str, FileSnapshot]:
|
|
713
|
+
snapshots: dict[str, FileSnapshot] = {}
|
|
714
|
+
for relpath in self.config.target_files:
|
|
715
|
+
abspath = self.config.repo_root / relpath
|
|
716
|
+
if not abspath.exists():
|
|
717
|
+
raise SyncError(f"Missing required file for sync: {abspath}")
|
|
718
|
+
content = abspath.read_text(encoding="utf-8")
|
|
719
|
+
stat = abspath.stat()
|
|
720
|
+
snapshots[relpath] = FileSnapshot(
|
|
721
|
+
relpath=relpath,
|
|
722
|
+
abspath=abspath,
|
|
723
|
+
content=content,
|
|
724
|
+
content_hash=_sha256(content),
|
|
725
|
+
mtime=stat.st_mtime,
|
|
726
|
+
)
|
|
727
|
+
return snapshots
|
|
728
|
+
|
|
729
|
+
def _fan_out(self, content: str, *, canonical_hash: str) -> dict[str, str]:
|
|
730
|
+
outcomes: dict[str, str] = {}
|
|
731
|
+
for relpath in self.config.target_files:
|
|
732
|
+
path = self.config.repo_root / relpath
|
|
733
|
+
current = path.read_text(encoding="utf-8")
|
|
734
|
+
if _sha256(current) == canonical_hash:
|
|
735
|
+
outcomes[relpath] = "unchanged"
|
|
736
|
+
continue
|
|
737
|
+
if self.config.dry_run:
|
|
738
|
+
outcomes[relpath] = "dry-run-skip"
|
|
739
|
+
continue
|
|
740
|
+
_atomic_write_text(path, content)
|
|
741
|
+
outcomes[relpath] = "updated"
|
|
742
|
+
return outcomes
|
|
743
|
+
|
|
744
|
+
def _update_ledger(self, *, canonical: CentralDocument) -> None:
|
|
745
|
+
snapshots = self._collect_snapshots()
|
|
746
|
+
self.ledger.last_revision = canonical.revision
|
|
747
|
+
self.ledger.last_source_file = canonical.source_file
|
|
748
|
+
self.ledger.synced_hash = canonical.content_hash
|
|
749
|
+
self.ledger.synced_content = canonical.content
|
|
750
|
+
self.ledger.file_hashes = {rel: snap.content_hash for rel, snap in snapshots.items()}
|
|
751
|
+
if not self.config.dry_run:
|
|
752
|
+
self.config.sync_dir.mkdir(parents=True, exist_ok=True)
|
|
753
|
+
self.ledger.save(self.config.ledger_path)
|
|
754
|
+
|
|
755
|
+
def _write_conflict_artifact(
|
|
756
|
+
self,
|
|
757
|
+
*,
|
|
758
|
+
reason: str,
|
|
759
|
+
snapshots: dict[str, FileSnapshot],
|
|
760
|
+
central: CentralDocument | None,
|
|
761
|
+
) -> Path:
|
|
762
|
+
conflict_dir = self.config.resolved_conflict_dir
|
|
763
|
+
conflict_dir.mkdir(parents=True, exist_ok=True)
|
|
764
|
+
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
|
765
|
+
artifact = conflict_dir / f".sync-conflict-{ts}.md"
|
|
766
|
+
|
|
767
|
+
lines = [
|
|
768
|
+
"# Sync Conflict",
|
|
769
|
+
"",
|
|
770
|
+
f"- reason: {reason}",
|
|
771
|
+
f"- timestamp_utc: {datetime.now(UTC).isoformat()}",
|
|
772
|
+
f"- central_revision: {central.revision if central else '<none>'}",
|
|
773
|
+
f"- central_hash: {central.content_hash if central else '<none>'}",
|
|
774
|
+
"",
|
|
775
|
+
"## Local Hashes",
|
|
776
|
+
]
|
|
777
|
+
for relpath in self.config.target_files:
|
|
778
|
+
lines.append(f"- {relpath}: {snapshots[relpath].content_hash}")
|
|
779
|
+
|
|
780
|
+
lines.extend(
|
|
781
|
+
[
|
|
782
|
+
"",
|
|
783
|
+
"## Next Actions",
|
|
784
|
+
"- Resolve conflicting edits manually.",
|
|
785
|
+
"- Re-run `vds-cli docs watch-agents --once --repo-root <repo>` after resolution.",
|
|
786
|
+
]
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
_atomic_write_text(artifact, "\n".join(lines) + "\n")
|
|
790
|
+
return artifact
|
|
791
|
+
|
|
792
|
+
def _needs_startup_strategy(self, central: CentralDocument | None) -> bool:
|
|
793
|
+
if not self.ledger.file_hashes:
|
|
794
|
+
return True
|
|
795
|
+
if central and self.ledger.last_revision is None:
|
|
796
|
+
return True
|
|
797
|
+
return False
|
|
798
|
+
|
|
799
|
+
def _snapshot_hash_map(self, snapshots: dict[str, FileSnapshot]) -> dict[str, str]:
|
|
800
|
+
return {rel: snap.content_hash for rel, snap in snapshots.items()}
|
|
801
|
+
|
|
802
|
+
def _select_local_source_without_baseline(self, snapshots: dict[str, FileSnapshot]) -> str | None:
|
|
803
|
+
hashes = {rel: snap.content_hash for rel, snap in snapshots.items()}
|
|
804
|
+
groups = _hash_groups(hashes)
|
|
805
|
+
if len(groups) == 1:
|
|
806
|
+
return self.config.target_files[0]
|
|
807
|
+
if len(groups) == 2:
|
|
808
|
+
newest = max(snapshots.values(), key=lambda item: item.mtime)
|
|
809
|
+
return newest.relpath
|
|
810
|
+
return None
|
|
811
|
+
|
|
812
|
+
def _select_single_modified_candidate(
|
|
813
|
+
self,
|
|
814
|
+
snapshots: dict[str, FileSnapshot],
|
|
815
|
+
*,
|
|
816
|
+
baseline_hash: str,
|
|
817
|
+
) -> str | None:
|
|
818
|
+
non_baseline = [rel for rel, snap in snapshots.items() if snap.content_hash != baseline_hash]
|
|
819
|
+
if len(non_baseline) == 1:
|
|
820
|
+
return non_baseline[0]
|
|
821
|
+
return None
|
|
822
|
+
|
|
823
|
+
def _retry_with_safe_merge(
|
|
824
|
+
self,
|
|
825
|
+
*,
|
|
826
|
+
source: str,
|
|
827
|
+
local_content: str,
|
|
828
|
+
reason: str,
|
|
829
|
+
) -> CentralDocument | None:
|
|
830
|
+
latest = self.backend.pull()
|
|
831
|
+
if latest is None:
|
|
832
|
+
return None
|
|
833
|
+
merged = self._safe_three_way_merge(
|
|
834
|
+
base_content=self.ledger.synced_content,
|
|
835
|
+
local_content=local_content,
|
|
836
|
+
remote_content=latest.content,
|
|
837
|
+
)
|
|
838
|
+
if merged is None:
|
|
839
|
+
return None
|
|
840
|
+
try:
|
|
841
|
+
canonical = self.backend.push_if_match(
|
|
842
|
+
content=merged,
|
|
843
|
+
source_file=source,
|
|
844
|
+
expected_revision=latest.revision,
|
|
845
|
+
)
|
|
846
|
+
except RevisionConflictError:
|
|
847
|
+
return None
|
|
848
|
+
self._log(
|
|
849
|
+
"merge-retry-applied",
|
|
850
|
+
reason=reason,
|
|
851
|
+
source_file=source,
|
|
852
|
+
source_hash=_sha256(local_content),
|
|
853
|
+
base_revision=self.ledger.last_revision,
|
|
854
|
+
remote_revision=latest.revision,
|
|
855
|
+
merged_revision=canonical.revision,
|
|
856
|
+
conflict_state="merge-applied",
|
|
857
|
+
)
|
|
858
|
+
return canonical
|
|
859
|
+
|
|
860
|
+
def _safe_three_way_merge(
|
|
861
|
+
self,
|
|
862
|
+
*,
|
|
863
|
+
base_content: str | None,
|
|
864
|
+
local_content: str,
|
|
865
|
+
remote_content: str,
|
|
866
|
+
) -> str | None:
|
|
867
|
+
if base_content is None:
|
|
868
|
+
return None
|
|
869
|
+
if local_content == remote_content:
|
|
870
|
+
return local_content
|
|
871
|
+
if local_content == base_content:
|
|
872
|
+
return remote_content
|
|
873
|
+
if remote_content == base_content:
|
|
874
|
+
return local_content
|
|
875
|
+
|
|
876
|
+
base_lines = base_content.splitlines(keepends=True)
|
|
877
|
+
local_edits = _line_edits(base_lines, local_content.splitlines(keepends=True))
|
|
878
|
+
remote_edits = _line_edits(base_lines, remote_content.splitlines(keepends=True))
|
|
879
|
+
|
|
880
|
+
merged_edits: list[LineEdit] = []
|
|
881
|
+
for edit in [*local_edits, *remote_edits]:
|
|
882
|
+
if any(
|
|
883
|
+
existing.start == edit.start
|
|
884
|
+
and existing.end == edit.end
|
|
885
|
+
and existing.replacement == edit.replacement
|
|
886
|
+
for existing in merged_edits
|
|
887
|
+
):
|
|
888
|
+
continue
|
|
889
|
+
merged_edits.append(edit)
|
|
890
|
+
|
|
891
|
+
for index, left in enumerate(merged_edits):
|
|
892
|
+
for right in merged_edits[index + 1 :]:
|
|
893
|
+
if _edits_conflict(left, right):
|
|
894
|
+
return None
|
|
895
|
+
|
|
896
|
+
merged = list(base_lines)
|
|
897
|
+
for edit in sorted(merged_edits, key=lambda item: (item.start, item.end), reverse=True):
|
|
898
|
+
merged[edit.start : edit.end] = list(edit.replacement)
|
|
899
|
+
return "".join(merged)
|
|
900
|
+
|
|
901
|
+
def _log(self, event: str, **fields: object) -> None:
|
|
902
|
+
payload = {
|
|
903
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
904
|
+
"event": event,
|
|
905
|
+
**fields,
|
|
906
|
+
}
|
|
907
|
+
line = json.dumps(payload, ensure_ascii=True)
|
|
908
|
+
if self.config.log_json:
|
|
909
|
+
print(line)
|
|
910
|
+
self.config.sync_dir.mkdir(parents=True, exist_ok=True)
|
|
911
|
+
for attempt in range(4):
|
|
912
|
+
try:
|
|
913
|
+
with self.config.log_path.open("a", encoding="utf-8") as fh:
|
|
914
|
+
fh.write(line + "\n")
|
|
915
|
+
return
|
|
916
|
+
except OSError as exc:
|
|
917
|
+
if exc.errno in {EDEADLK, EBUSY, EACCES, EPERM} and attempt < 3:
|
|
918
|
+
time.sleep(0.05 * (attempt + 1))
|
|
919
|
+
continue
|
|
920
|
+
# Do not crash sync loop because of local log sink write failures.
|
|
921
|
+
print(
|
|
922
|
+
json.dumps(
|
|
923
|
+
{
|
|
924
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
925
|
+
"event": "log-write-failed",
|
|
926
|
+
"error": str(exc),
|
|
927
|
+
"log_path": str(self.config.log_path),
|
|
928
|
+
},
|
|
929
|
+
ensure_ascii=True,
|
|
930
|
+
),
|
|
931
|
+
file=sys.stderr,
|
|
932
|
+
)
|
|
933
|
+
return
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def find_repo_root(start: Path) -> Path:
|
|
937
|
+
"""Find repository root by walking up until AGENTS and mirrored instruction files exist."""
|
|
938
|
+
current = start.resolve()
|
|
939
|
+
while True:
|
|
940
|
+
if all((current / relpath).exists() for relpath in TARGET_FILES):
|
|
941
|
+
return current
|
|
942
|
+
if current.parent == current:
|
|
943
|
+
raise SyncError(
|
|
944
|
+
"Could not locate repository root containing AGENTS.md plus mirrored legacy instruction files "
|
|
945
|
+
"(CLAUDE.md and .github/copilot-instructions.md)"
|
|
946
|
+
)
|
|
947
|
+
current = current.parent
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def run_watch_agents(
|
|
951
|
+
*,
|
|
952
|
+
repo_root: Path,
|
|
953
|
+
reconcile_interval_seconds: int,
|
|
954
|
+
debounce_ms: int,
|
|
955
|
+
watch_backend: WatchBackend,
|
|
956
|
+
central_backend: str,
|
|
957
|
+
central_contract: str,
|
|
958
|
+
central_state_url: str | None,
|
|
959
|
+
central_timeout_seconds: int,
|
|
960
|
+
central_auth_token: str | None,
|
|
961
|
+
central_memory_id: str | None,
|
|
962
|
+
central_user_id: str,
|
|
963
|
+
central_session_id: str,
|
|
964
|
+
central_confluence_server: str,
|
|
965
|
+
central_confluence_page_id: str | None,
|
|
966
|
+
central_confluence_property_key: str,
|
|
967
|
+
dry_run: bool,
|
|
968
|
+
conflict_dir: Path | None,
|
|
969
|
+
log_json: bool,
|
|
970
|
+
once: bool,
|
|
971
|
+
) -> None:
|
|
972
|
+
"""Run the watch-agents command with resolved config."""
|
|
973
|
+
resolved_root = find_repo_root(repo_root)
|
|
974
|
+
config = SyncConfig(
|
|
975
|
+
repo_root=resolved_root,
|
|
976
|
+
reconcile_interval_seconds=reconcile_interval_seconds,
|
|
977
|
+
debounce_ms=debounce_ms,
|
|
978
|
+
watch_backend=watch_backend,
|
|
979
|
+
central_backend=central_backend,
|
|
980
|
+
central_contract=central_contract,
|
|
981
|
+
central_state_url=central_state_url,
|
|
982
|
+
central_timeout_seconds=central_timeout_seconds,
|
|
983
|
+
central_auth_token=central_auth_token,
|
|
984
|
+
central_memory_id=central_memory_id,
|
|
985
|
+
central_user_id=central_user_id,
|
|
986
|
+
central_session_id=central_session_id,
|
|
987
|
+
central_confluence_server=central_confluence_server,
|
|
988
|
+
central_confluence_page_id=central_confluence_page_id,
|
|
989
|
+
central_confluence_property_key=central_confluence_property_key,
|
|
990
|
+
dry_run=dry_run,
|
|
991
|
+
conflict_dir=conflict_dir,
|
|
992
|
+
log_json=log_json,
|
|
993
|
+
)
|
|
994
|
+
engine = DocsSyncEngine(config=config)
|
|
995
|
+
engine.watch(once=once)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _build_backend(config: SyncConfig) -> CentralStateBackend:
|
|
999
|
+
backend = config.central_backend.lower().strip()
|
|
1000
|
+
if backend == "local":
|
|
1001
|
+
return LocalJsonCentralStateBackend(config.central_state_path)
|
|
1002
|
+
if backend == "http":
|
|
1003
|
+
contract = config.central_contract.lower().strip()
|
|
1004
|
+
if contract == "generic":
|
|
1005
|
+
if not config.central_state_url:
|
|
1006
|
+
raise SyncError(
|
|
1007
|
+
"central_contract='generic' requires --central-state-url."
|
|
1008
|
+
)
|
|
1009
|
+
return HttpCentralStateBackend(
|
|
1010
|
+
client=SyncApiClient(
|
|
1011
|
+
endpoint=config.central_state_url,
|
|
1012
|
+
timeout_seconds=config.central_timeout_seconds,
|
|
1013
|
+
auth_token=config.central_auth_token,
|
|
1014
|
+
)
|
|
1015
|
+
)
|
|
1016
|
+
if contract == "vds-ai-memory":
|
|
1017
|
+
base_url = config.central_state_url or os.getenv("VDS_AI_MEMORY_BASE_URL")
|
|
1018
|
+
if not base_url:
|
|
1019
|
+
raise SyncError(
|
|
1020
|
+
"central_contract='vds-ai-memory' requires --central-state-url or VDS_AI_MEMORY_BASE_URL."
|
|
1021
|
+
)
|
|
1022
|
+
memory_id = config.central_memory_id
|
|
1023
|
+
if not memory_id:
|
|
1024
|
+
raise SyncError(
|
|
1025
|
+
"central_contract='vds-ai-memory' requires --central-memory-id."
|
|
1026
|
+
)
|
|
1027
|
+
auth_token = config.central_auth_token or os.getenv("VDS_AI_MEMORY_API_KEY")
|
|
1028
|
+
return HttpCentralStateBackend(
|
|
1029
|
+
client=VdsAiMemorySyncApiClient(
|
|
1030
|
+
base_url=base_url,
|
|
1031
|
+
memory_id=memory_id,
|
|
1032
|
+
user_id=config.central_user_id,
|
|
1033
|
+
session_id=config.central_session_id,
|
|
1034
|
+
timeout_seconds=config.central_timeout_seconds,
|
|
1035
|
+
auth_token=auth_token,
|
|
1036
|
+
)
|
|
1037
|
+
)
|
|
1038
|
+
if contract == "confluence-property":
|
|
1039
|
+
page_id = config.central_confluence_page_id
|
|
1040
|
+
if not page_id:
|
|
1041
|
+
raise SyncError(
|
|
1042
|
+
"central_contract='confluence-property' requires --central-confluence-page-id."
|
|
1043
|
+
)
|
|
1044
|
+
base_url = _resolve_confluence_base_url(
|
|
1045
|
+
server=config.central_confluence_server,
|
|
1046
|
+
override=config.central_state_url,
|
|
1047
|
+
)
|
|
1048
|
+
auth_token = _resolve_confluence_token(
|
|
1049
|
+
server=config.central_confluence_server,
|
|
1050
|
+
explicit_token=config.central_auth_token,
|
|
1051
|
+
)
|
|
1052
|
+
username = os.getenv("VDS_USERNAME")
|
|
1053
|
+
password = os.getenv("VDS_PASSWORD")
|
|
1054
|
+
if not auth_token and not (username and password):
|
|
1055
|
+
raise SyncError(
|
|
1056
|
+
"Confluence credentials missing. Provide VDS_USERNAME+VDS_PASSWORD or "
|
|
1057
|
+
"INTERNAL_CONFLUENCE_TOKEN/EXTERNAL_CONFLUENCE_TOKEN (or --central-auth-token)."
|
|
1058
|
+
)
|
|
1059
|
+
return HttpCentralStateBackend(
|
|
1060
|
+
client=ConfluencePropertySyncApiClient(
|
|
1061
|
+
base_url=base_url,
|
|
1062
|
+
page_id=page_id,
|
|
1063
|
+
property_key=config.central_confluence_property_key,
|
|
1064
|
+
timeout_seconds=config.central_timeout_seconds,
|
|
1065
|
+
auth_token=auth_token,
|
|
1066
|
+
username=username,
|
|
1067
|
+
password=password,
|
|
1068
|
+
)
|
|
1069
|
+
)
|
|
1070
|
+
raise SyncError(
|
|
1071
|
+
"Invalid central contract for HTTP backend. Expected: generic, vds-ai-memory, confluence-property."
|
|
1072
|
+
)
|
|
1073
|
+
raise SyncError(
|
|
1074
|
+
f"Invalid central backend '{config.central_backend}'. Expected: local, http."
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _resolve_confluence_base_url(*, server: str, override: str | None) -> str:
|
|
1079
|
+
if override:
|
|
1080
|
+
return override
|
|
1081
|
+
server_lower = server.lower().strip()
|
|
1082
|
+
if server_lower in {"internal", "int", "main"}:
|
|
1083
|
+
return os.getenv("CONFLUENCE_INTERNAL_URL", "http://confluence.digital.vn")
|
|
1084
|
+
if server_lower in {"external", "ext", "outsource"}:
|
|
1085
|
+
return os.getenv("CONFLUENCE_EXTERNAL_URL", "http://10.254.136.35:8090")
|
|
1086
|
+
raise SyncError(
|
|
1087
|
+
f"Unknown confluence server '{server}'. Expected internal or external."
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _resolve_confluence_token(*, server: str, explicit_token: str | None) -> str | None:
|
|
1092
|
+
if explicit_token:
|
|
1093
|
+
return explicit_token
|
|
1094
|
+
server_lower = server.lower().strip()
|
|
1095
|
+
if server_lower in {"internal", "int", "main"}:
|
|
1096
|
+
return os.getenv("INTERNAL_CONFLUENCE_TOKEN")
|
|
1097
|
+
if server_lower in {"external", "ext", "outsource"}:
|
|
1098
|
+
return os.getenv("EXTERNAL_CONFLUENCE_TOKEN")
|
|
1099
|
+
return None
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _sha256(content: str) -> str:
|
|
1103
|
+
import hashlib
|
|
1104
|
+
|
|
1105
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _hash_groups(hashes: dict[str, str]) -> dict[str, list[str]]:
|
|
1109
|
+
grouped: dict[str, list[str]] = {}
|
|
1110
|
+
for relpath, digest in hashes.items():
|
|
1111
|
+
grouped.setdefault(digest, []).append(relpath)
|
|
1112
|
+
return grouped
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _atomic_write_text(path: Path, content: str) -> None:
|
|
1116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1117
|
+
temp_path: Path | None = None
|
|
1118
|
+
try:
|
|
1119
|
+
with tempfile.NamedTemporaryFile(
|
|
1120
|
+
mode="w",
|
|
1121
|
+
encoding="utf-8",
|
|
1122
|
+
dir=path.parent,
|
|
1123
|
+
delete=False,
|
|
1124
|
+
prefix=f".{path.name}.",
|
|
1125
|
+
suffix=".tmp",
|
|
1126
|
+
) as fh:
|
|
1127
|
+
fh.write(content)
|
|
1128
|
+
fh.flush()
|
|
1129
|
+
os.fsync(fh.fileno())
|
|
1130
|
+
temp_path = Path(fh.name)
|
|
1131
|
+
|
|
1132
|
+
for attempt in range(4):
|
|
1133
|
+
try:
|
|
1134
|
+
os.replace(temp_path, path)
|
|
1135
|
+
return
|
|
1136
|
+
except OSError as exc:
|
|
1137
|
+
if not _is_retryable_replace_error(exc) or attempt >= 3:
|
|
1138
|
+
raise
|
|
1139
|
+
time.sleep(0.05 * (attempt + 1))
|
|
1140
|
+
finally:
|
|
1141
|
+
if temp_path is not None and temp_path.exists():
|
|
1142
|
+
temp_path.unlink(missing_ok=True)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _is_retryable_replace_error(exc: OSError) -> bool:
|
|
1146
|
+
if isinstance(exc, PermissionError):
|
|
1147
|
+
return True
|
|
1148
|
+
if exc.errno in {EACCES, EPERM, EBUSY}:
|
|
1149
|
+
return True
|
|
1150
|
+
return False
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _write_json_atomic(path: Path, payload: dict[str, object]) -> None:
|
|
1154
|
+
_atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=True) + "\n")
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _as_central_document(document: SyncApiDocument) -> CentralDocument:
|
|
1158
|
+
return CentralDocument(
|
|
1159
|
+
content=document.content,
|
|
1160
|
+
revision=document.revision,
|
|
1161
|
+
content_hash=document.content_hash,
|
|
1162
|
+
source_file=document.source_file,
|
|
1163
|
+
updated_at=document.updated_at,
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _load_watchfiles_watch():
|
|
1168
|
+
try:
|
|
1169
|
+
from watchfiles import watch # type: ignore
|
|
1170
|
+
except ModuleNotFoundError:
|
|
1171
|
+
return None
|
|
1172
|
+
return watch
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _line_edits(base_lines: list[str], target_lines: list[str]) -> list[LineEdit]:
|
|
1176
|
+
edits: list[LineEdit] = []
|
|
1177
|
+
matcher = SequenceMatcher(a=base_lines, b=target_lines)
|
|
1178
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
1179
|
+
if tag == "equal":
|
|
1180
|
+
continue
|
|
1181
|
+
edits.append(LineEdit(start=i1, end=i2, replacement=tuple(target_lines[j1:j2])))
|
|
1182
|
+
return edits
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _edits_conflict(left: LineEdit, right: LineEdit) -> bool:
|
|
1186
|
+
if (
|
|
1187
|
+
left.start == right.start
|
|
1188
|
+
and left.end == right.end
|
|
1189
|
+
and left.replacement == right.replacement
|
|
1190
|
+
):
|
|
1191
|
+
return False
|
|
1192
|
+
|
|
1193
|
+
left_insert = left.start == left.end
|
|
1194
|
+
right_insert = right.start == right.end
|
|
1195
|
+
|
|
1196
|
+
if left_insert and right_insert:
|
|
1197
|
+
return left.start == right.start
|
|
1198
|
+
if left_insert:
|
|
1199
|
+
return right.start <= left.start < right.end
|
|
1200
|
+
if right_insert:
|
|
1201
|
+
return left.start <= right.start < left.end
|
|
1202
|
+
|
|
1203
|
+
return not (left.end <= right.start or right.end <= left.start)
|