attune-ai 2.0.0__py3-none-any.whl
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.
- attune/__init__.py +358 -0
- attune/adaptive/__init__.py +13 -0
- attune/adaptive/task_complexity.py +127 -0
- attune/agent_monitoring.py +414 -0
- attune/cache/__init__.py +117 -0
- attune/cache/base.py +166 -0
- attune/cache/dependency_manager.py +256 -0
- attune/cache/hash_only.py +251 -0
- attune/cache/hybrid.py +457 -0
- attune/cache/storage.py +285 -0
- attune/cache_monitor.py +356 -0
- attune/cache_stats.py +298 -0
- attune/cli/__init__.py +152 -0
- attune/cli/__main__.py +12 -0
- attune/cli/commands/__init__.py +1 -0
- attune/cli/commands/batch.py +264 -0
- attune/cli/commands/cache.py +248 -0
- attune/cli/commands/help.py +331 -0
- attune/cli/commands/info.py +140 -0
- attune/cli/commands/inspect.py +436 -0
- attune/cli/commands/inspection.py +57 -0
- attune/cli/commands/memory.py +48 -0
- attune/cli/commands/metrics.py +92 -0
- attune/cli/commands/orchestrate.py +184 -0
- attune/cli/commands/patterns.py +207 -0
- attune/cli/commands/profiling.py +202 -0
- attune/cli/commands/provider.py +98 -0
- attune/cli/commands/routing.py +285 -0
- attune/cli/commands/setup.py +96 -0
- attune/cli/commands/status.py +235 -0
- attune/cli/commands/sync.py +166 -0
- attune/cli/commands/tier.py +121 -0
- attune/cli/commands/utilities.py +114 -0
- attune/cli/commands/workflow.py +579 -0
- attune/cli/core.py +32 -0
- attune/cli/parsers/__init__.py +68 -0
- attune/cli/parsers/batch.py +118 -0
- attune/cli/parsers/cache.py +65 -0
- attune/cli/parsers/help.py +41 -0
- attune/cli/parsers/info.py +26 -0
- attune/cli/parsers/inspect.py +66 -0
- attune/cli/parsers/metrics.py +42 -0
- attune/cli/parsers/orchestrate.py +61 -0
- attune/cli/parsers/patterns.py +54 -0
- attune/cli/parsers/provider.py +40 -0
- attune/cli/parsers/routing.py +110 -0
- attune/cli/parsers/setup.py +42 -0
- attune/cli/parsers/status.py +47 -0
- attune/cli/parsers/sync.py +31 -0
- attune/cli/parsers/tier.py +33 -0
- attune/cli/parsers/workflow.py +77 -0
- attune/cli/utils/__init__.py +1 -0
- attune/cli/utils/data.py +242 -0
- attune/cli/utils/helpers.py +68 -0
- attune/cli_legacy.py +3957 -0
- attune/cli_minimal.py +1159 -0
- attune/cli_router.py +437 -0
- attune/cli_unified.py +814 -0
- attune/config/__init__.py +66 -0
- attune/config/xml_config.py +286 -0
- attune/config.py +545 -0
- attune/coordination.py +870 -0
- attune/core.py +1511 -0
- attune/core_modules/__init__.py +15 -0
- attune/cost_tracker.py +626 -0
- attune/dashboard/__init__.py +41 -0
- attune/dashboard/app.py +512 -0
- attune/dashboard/simple_server.py +435 -0
- attune/dashboard/standalone_server.py +547 -0
- attune/discovery.py +306 -0
- attune/emergence.py +306 -0
- attune/exceptions.py +123 -0
- attune/feedback_loops.py +373 -0
- attune/hot_reload/README.md +473 -0
- attune/hot_reload/__init__.py +62 -0
- attune/hot_reload/config.py +83 -0
- attune/hot_reload/integration.py +229 -0
- attune/hot_reload/reloader.py +298 -0
- attune/hot_reload/watcher.py +183 -0
- attune/hot_reload/websocket.py +177 -0
- attune/levels.py +577 -0
- attune/leverage_points.py +441 -0
- attune/logging_config.py +261 -0
- attune/mcp/__init__.py +10 -0
- attune/mcp/server.py +506 -0
- attune/memory/__init__.py +237 -0
- attune/memory/claude_memory.py +469 -0
- attune/memory/config.py +224 -0
- attune/memory/control_panel.py +1290 -0
- attune/memory/control_panel_support.py +145 -0
- attune/memory/cross_session.py +845 -0
- attune/memory/edges.py +179 -0
- attune/memory/encryption.py +159 -0
- attune/memory/file_session.py +770 -0
- attune/memory/graph.py +570 -0
- attune/memory/long_term.py +913 -0
- attune/memory/long_term_types.py +99 -0
- attune/memory/mixins/__init__.py +25 -0
- attune/memory/mixins/backend_init_mixin.py +249 -0
- attune/memory/mixins/capabilities_mixin.py +208 -0
- attune/memory/mixins/handoff_mixin.py +208 -0
- attune/memory/mixins/lifecycle_mixin.py +49 -0
- attune/memory/mixins/long_term_mixin.py +352 -0
- attune/memory/mixins/promotion_mixin.py +109 -0
- attune/memory/mixins/short_term_mixin.py +182 -0
- attune/memory/nodes.py +179 -0
- attune/memory/redis_bootstrap.py +540 -0
- attune/memory/security/__init__.py +31 -0
- attune/memory/security/audit_logger.py +932 -0
- attune/memory/security/pii_scrubber.py +640 -0
- attune/memory/security/secrets_detector.py +678 -0
- attune/memory/short_term.py +2192 -0
- attune/memory/simple_storage.py +302 -0
- attune/memory/storage/__init__.py +15 -0
- attune/memory/storage_backend.py +167 -0
- attune/memory/summary_index.py +583 -0
- attune/memory/types.py +446 -0
- attune/memory/unified.py +182 -0
- attune/meta_workflows/__init__.py +74 -0
- attune/meta_workflows/agent_creator.py +248 -0
- attune/meta_workflows/builtin_templates.py +567 -0
- attune/meta_workflows/cli_commands/__init__.py +56 -0
- attune/meta_workflows/cli_commands/agent_commands.py +321 -0
- attune/meta_workflows/cli_commands/analytics_commands.py +442 -0
- attune/meta_workflows/cli_commands/config_commands.py +232 -0
- attune/meta_workflows/cli_commands/memory_commands.py +182 -0
- attune/meta_workflows/cli_commands/template_commands.py +354 -0
- attune/meta_workflows/cli_commands/workflow_commands.py +382 -0
- attune/meta_workflows/cli_meta_workflows.py +59 -0
- attune/meta_workflows/form_engine.py +292 -0
- attune/meta_workflows/intent_detector.py +409 -0
- attune/meta_workflows/models.py +569 -0
- attune/meta_workflows/pattern_learner.py +738 -0
- attune/meta_workflows/plan_generator.py +384 -0
- attune/meta_workflows/session_context.py +397 -0
- attune/meta_workflows/template_registry.py +229 -0
- attune/meta_workflows/workflow.py +984 -0
- attune/metrics/__init__.py +12 -0
- attune/metrics/collector.py +31 -0
- attune/metrics/prompt_metrics.py +194 -0
- attune/models/__init__.py +172 -0
- attune/models/__main__.py +13 -0
- attune/models/adaptive_routing.py +437 -0
- attune/models/auth_cli.py +444 -0
- attune/models/auth_strategy.py +450 -0
- attune/models/cli.py +655 -0
- attune/models/empathy_executor.py +354 -0
- attune/models/executor.py +257 -0
- attune/models/fallback.py +762 -0
- attune/models/provider_config.py +282 -0
- attune/models/registry.py +472 -0
- attune/models/tasks.py +359 -0
- attune/models/telemetry/__init__.py +71 -0
- attune/models/telemetry/analytics.py +594 -0
- attune/models/telemetry/backend.py +196 -0
- attune/models/telemetry/data_models.py +431 -0
- attune/models/telemetry/storage.py +489 -0
- attune/models/token_estimator.py +420 -0
- attune/models/validation.py +280 -0
- attune/monitoring/__init__.py +52 -0
- attune/monitoring/alerts.py +946 -0
- attune/monitoring/alerts_cli.py +448 -0
- attune/monitoring/multi_backend.py +271 -0
- attune/monitoring/otel_backend.py +362 -0
- attune/optimization/__init__.py +19 -0
- attune/optimization/context_optimizer.py +272 -0
- attune/orchestration/__init__.py +67 -0
- attune/orchestration/agent_templates.py +707 -0
- attune/orchestration/config_store.py +499 -0
- attune/orchestration/execution_strategies.py +2111 -0
- attune/orchestration/meta_orchestrator.py +1168 -0
- attune/orchestration/pattern_learner.py +696 -0
- attune/orchestration/real_tools.py +931 -0
- attune/pattern_cache.py +187 -0
- attune/pattern_library.py +542 -0
- attune/patterns/debugging/all_patterns.json +81 -0
- attune/patterns/debugging/workflow_20260107_1770825e.json +77 -0
- attune/patterns/refactoring_memory.json +89 -0
- attune/persistence.py +564 -0
- attune/platform_utils.py +265 -0
- attune/plugins/__init__.py +28 -0
- attune/plugins/base.py +361 -0
- attune/plugins/registry.py +268 -0
- attune/project_index/__init__.py +32 -0
- attune/project_index/cli.py +335 -0
- attune/project_index/index.py +667 -0
- attune/project_index/models.py +504 -0
- attune/project_index/reports.py +474 -0
- attune/project_index/scanner.py +777 -0
- attune/project_index/scanner_parallel.py +291 -0
- attune/prompts/__init__.py +61 -0
- attune/prompts/config.py +77 -0
- attune/prompts/context.py +177 -0
- attune/prompts/parser.py +285 -0
- attune/prompts/registry.py +313 -0
- attune/prompts/templates.py +208 -0
- attune/redis_config.py +302 -0
- attune/redis_memory.py +799 -0
- attune/resilience/__init__.py +56 -0
- attune/resilience/circuit_breaker.py +256 -0
- attune/resilience/fallback.py +179 -0
- attune/resilience/health.py +300 -0
- attune/resilience/retry.py +209 -0
- attune/resilience/timeout.py +135 -0
- attune/routing/__init__.py +43 -0
- attune/routing/chain_executor.py +433 -0
- attune/routing/classifier.py +217 -0
- attune/routing/smart_router.py +234 -0
- attune/routing/workflow_registry.py +343 -0
- attune/scaffolding/README.md +589 -0
- attune/scaffolding/__init__.py +35 -0
- attune/scaffolding/__main__.py +14 -0
- attune/scaffolding/cli.py +240 -0
- attune/scaffolding/templates/base_wizard.py.jinja2 +121 -0
- attune/scaffolding/templates/coach_wizard.py.jinja2 +321 -0
- attune/scaffolding/templates/domain_wizard.py.jinja2 +408 -0
- attune/scaffolding/templates/linear_flow_wizard.py.jinja2 +203 -0
- attune/socratic/__init__.py +256 -0
- attune/socratic/ab_testing.py +958 -0
- attune/socratic/blueprint.py +533 -0
- attune/socratic/cli.py +703 -0
- attune/socratic/collaboration.py +1114 -0
- attune/socratic/domain_templates.py +924 -0
- attune/socratic/embeddings.py +738 -0
- attune/socratic/engine.py +794 -0
- attune/socratic/explainer.py +682 -0
- attune/socratic/feedback.py +772 -0
- attune/socratic/forms.py +629 -0
- attune/socratic/generator.py +732 -0
- attune/socratic/llm_analyzer.py +637 -0
- attune/socratic/mcp_server.py +702 -0
- attune/socratic/session.py +312 -0
- attune/socratic/storage.py +667 -0
- attune/socratic/success.py +730 -0
- attune/socratic/visual_editor.py +860 -0
- attune/socratic/web_ui.py +958 -0
- attune/telemetry/__init__.py +39 -0
- attune/telemetry/agent_coordination.py +475 -0
- attune/telemetry/agent_tracking.py +367 -0
- attune/telemetry/approval_gates.py +545 -0
- attune/telemetry/cli.py +1231 -0
- attune/telemetry/commands/__init__.py +14 -0
- attune/telemetry/commands/dashboard_commands.py +696 -0
- attune/telemetry/event_streaming.py +409 -0
- attune/telemetry/feedback_loop.py +567 -0
- attune/telemetry/usage_tracker.py +591 -0
- attune/templates.py +754 -0
- attune/test_generator/__init__.py +38 -0
- attune/test_generator/__main__.py +14 -0
- attune/test_generator/cli.py +234 -0
- attune/test_generator/generator.py +355 -0
- attune/test_generator/risk_analyzer.py +216 -0
- attune/test_generator/templates/unit_test.py.jinja2 +272 -0
- attune/tier_recommender.py +384 -0
- attune/tools.py +183 -0
- attune/trust/__init__.py +28 -0
- attune/trust/circuit_breaker.py +579 -0
- attune/trust_building.py +527 -0
- attune/validation/__init__.py +19 -0
- attune/validation/xml_validator.py +281 -0
- attune/vscode_bridge.py +173 -0
- attune/workflow_commands.py +780 -0
- attune/workflow_patterns/__init__.py +33 -0
- attune/workflow_patterns/behavior.py +249 -0
- attune/workflow_patterns/core.py +76 -0
- attune/workflow_patterns/output.py +99 -0
- attune/workflow_patterns/registry.py +255 -0
- attune/workflow_patterns/structural.py +288 -0
- attune/workflows/__init__.py +539 -0
- attune/workflows/autonomous_test_gen.py +1268 -0
- attune/workflows/base.py +2667 -0
- attune/workflows/batch_processing.py +342 -0
- attune/workflows/bug_predict.py +1084 -0
- attune/workflows/builder.py +273 -0
- attune/workflows/caching.py +253 -0
- attune/workflows/code_review.py +1048 -0
- attune/workflows/code_review_adapters.py +312 -0
- attune/workflows/code_review_pipeline.py +722 -0
- attune/workflows/config.py +645 -0
- attune/workflows/dependency_check.py +644 -0
- attune/workflows/document_gen/__init__.py +25 -0
- attune/workflows/document_gen/config.py +30 -0
- attune/workflows/document_gen/report_formatter.py +162 -0
- attune/workflows/document_gen/workflow.py +1426 -0
- attune/workflows/document_manager.py +216 -0
- attune/workflows/document_manager_README.md +134 -0
- attune/workflows/documentation_orchestrator.py +1205 -0
- attune/workflows/history.py +510 -0
- attune/workflows/keyboard_shortcuts/__init__.py +39 -0
- attune/workflows/keyboard_shortcuts/generators.py +391 -0
- attune/workflows/keyboard_shortcuts/parsers.py +416 -0
- attune/workflows/keyboard_shortcuts/prompts.py +295 -0
- attune/workflows/keyboard_shortcuts/schema.py +193 -0
- attune/workflows/keyboard_shortcuts/workflow.py +509 -0
- attune/workflows/llm_base.py +363 -0
- attune/workflows/manage_docs.py +87 -0
- attune/workflows/manage_docs_README.md +134 -0
- attune/workflows/manage_documentation.py +821 -0
- attune/workflows/new_sample_workflow1.py +149 -0
- attune/workflows/new_sample_workflow1_README.md +150 -0
- attune/workflows/orchestrated_health_check.py +849 -0
- attune/workflows/orchestrated_release_prep.py +600 -0
- attune/workflows/output.py +413 -0
- attune/workflows/perf_audit.py +863 -0
- attune/workflows/pr_review.py +762 -0
- attune/workflows/progress.py +785 -0
- attune/workflows/progress_server.py +322 -0
- attune/workflows/progressive/README 2.md +454 -0
- attune/workflows/progressive/README.md +454 -0
- attune/workflows/progressive/__init__.py +82 -0
- attune/workflows/progressive/cli.py +219 -0
- attune/workflows/progressive/core.py +488 -0
- attune/workflows/progressive/orchestrator.py +723 -0
- attune/workflows/progressive/reports.py +520 -0
- attune/workflows/progressive/telemetry.py +274 -0
- attune/workflows/progressive/test_gen.py +495 -0
- attune/workflows/progressive/workflow.py +589 -0
- attune/workflows/refactor_plan.py +694 -0
- attune/workflows/release_prep.py +895 -0
- attune/workflows/release_prep_crew.py +969 -0
- attune/workflows/research_synthesis.py +404 -0
- attune/workflows/routing.py +168 -0
- attune/workflows/secure_release.py +593 -0
- attune/workflows/security_adapters.py +297 -0
- attune/workflows/security_audit.py +1329 -0
- attune/workflows/security_audit_phase3.py +355 -0
- attune/workflows/seo_optimization.py +633 -0
- attune/workflows/step_config.py +234 -0
- attune/workflows/telemetry_mixin.py +269 -0
- attune/workflows/test5.py +125 -0
- attune/workflows/test5_README.md +158 -0
- attune/workflows/test_coverage_boost_crew.py +849 -0
- attune/workflows/test_gen/__init__.py +52 -0
- attune/workflows/test_gen/ast_analyzer.py +249 -0
- attune/workflows/test_gen/config.py +88 -0
- attune/workflows/test_gen/data_models.py +38 -0
- attune/workflows/test_gen/report_formatter.py +289 -0
- attune/workflows/test_gen/test_templates.py +381 -0
- attune/workflows/test_gen/workflow.py +655 -0
- attune/workflows/test_gen.py +54 -0
- attune/workflows/test_gen_behavioral.py +477 -0
- attune/workflows/test_gen_parallel.py +341 -0
- attune/workflows/test_lifecycle.py +526 -0
- attune/workflows/test_maintenance.py +627 -0
- attune/workflows/test_maintenance_cli.py +590 -0
- attune/workflows/test_maintenance_crew.py +840 -0
- attune/workflows/test_runner.py +622 -0
- attune/workflows/tier_tracking.py +531 -0
- attune/workflows/xml_enhanced_crew.py +285 -0
- attune_ai-2.0.0.dist-info/METADATA +1026 -0
- attune_ai-2.0.0.dist-info/RECORD +457 -0
- attune_ai-2.0.0.dist-info/WHEEL +5 -0
- attune_ai-2.0.0.dist-info/entry_points.txt +26 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE +201 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +101 -0
- attune_ai-2.0.0.dist-info/top_level.txt +5 -0
- attune_healthcare/__init__.py +13 -0
- attune_healthcare/monitors/__init__.py +9 -0
- attune_healthcare/monitors/clinical_protocol_monitor.py +315 -0
- attune_healthcare/monitors/monitoring/__init__.py +44 -0
- attune_healthcare/monitors/monitoring/protocol_checker.py +300 -0
- attune_healthcare/monitors/monitoring/protocol_loader.py +214 -0
- attune_healthcare/monitors/monitoring/sensor_parsers.py +306 -0
- attune_healthcare/monitors/monitoring/trajectory_analyzer.py +389 -0
- attune_llm/README.md +553 -0
- attune_llm/__init__.py +28 -0
- attune_llm/agent_factory/__init__.py +53 -0
- attune_llm/agent_factory/adapters/__init__.py +85 -0
- attune_llm/agent_factory/adapters/autogen_adapter.py +312 -0
- attune_llm/agent_factory/adapters/crewai_adapter.py +483 -0
- attune_llm/agent_factory/adapters/haystack_adapter.py +298 -0
- attune_llm/agent_factory/adapters/langchain_adapter.py +362 -0
- attune_llm/agent_factory/adapters/langgraph_adapter.py +333 -0
- attune_llm/agent_factory/adapters/native.py +228 -0
- attune_llm/agent_factory/adapters/wizard_adapter.py +423 -0
- attune_llm/agent_factory/base.py +305 -0
- attune_llm/agent_factory/crews/__init__.py +67 -0
- attune_llm/agent_factory/crews/code_review.py +1113 -0
- attune_llm/agent_factory/crews/health_check.py +1262 -0
- attune_llm/agent_factory/crews/refactoring.py +1128 -0
- attune_llm/agent_factory/crews/security_audit.py +1018 -0
- attune_llm/agent_factory/decorators.py +287 -0
- attune_llm/agent_factory/factory.py +558 -0
- attune_llm/agent_factory/framework.py +193 -0
- attune_llm/agent_factory/memory_integration.py +328 -0
- attune_llm/agent_factory/resilient.py +320 -0
- attune_llm/agents_md/__init__.py +22 -0
- attune_llm/agents_md/loader.py +218 -0
- attune_llm/agents_md/parser.py +271 -0
- attune_llm/agents_md/registry.py +307 -0
- attune_llm/claude_memory.py +466 -0
- attune_llm/cli/__init__.py +8 -0
- attune_llm/cli/sync_claude.py +487 -0
- attune_llm/code_health.py +1313 -0
- attune_llm/commands/__init__.py +51 -0
- attune_llm/commands/context.py +375 -0
- attune_llm/commands/loader.py +301 -0
- attune_llm/commands/models.py +231 -0
- attune_llm/commands/parser.py +371 -0
- attune_llm/commands/registry.py +429 -0
- attune_llm/config/__init__.py +29 -0
- attune_llm/config/unified.py +291 -0
- attune_llm/context/__init__.py +22 -0
- attune_llm/context/compaction.py +455 -0
- attune_llm/context/manager.py +434 -0
- attune_llm/contextual_patterns.py +361 -0
- attune_llm/core.py +907 -0
- attune_llm/git_pattern_extractor.py +435 -0
- attune_llm/hooks/__init__.py +24 -0
- attune_llm/hooks/config.py +306 -0
- attune_llm/hooks/executor.py +289 -0
- attune_llm/hooks/registry.py +302 -0
- attune_llm/hooks/scripts/__init__.py +39 -0
- attune_llm/hooks/scripts/evaluate_session.py +201 -0
- attune_llm/hooks/scripts/first_time_init.py +285 -0
- attune_llm/hooks/scripts/pre_compact.py +207 -0
- attune_llm/hooks/scripts/session_end.py +183 -0
- attune_llm/hooks/scripts/session_start.py +163 -0
- attune_llm/hooks/scripts/suggest_compact.py +225 -0
- attune_llm/learning/__init__.py +30 -0
- attune_llm/learning/evaluator.py +438 -0
- attune_llm/learning/extractor.py +514 -0
- attune_llm/learning/storage.py +560 -0
- attune_llm/levels.py +227 -0
- attune_llm/pattern_confidence.py +414 -0
- attune_llm/pattern_resolver.py +272 -0
- attune_llm/pattern_summary.py +350 -0
- attune_llm/providers.py +967 -0
- attune_llm/routing/__init__.py +32 -0
- attune_llm/routing/model_router.py +362 -0
- attune_llm/security/IMPLEMENTATION_SUMMARY.md +413 -0
- attune_llm/security/PHASE2_COMPLETE.md +384 -0
- attune_llm/security/PHASE2_SECRETS_DETECTOR_COMPLETE.md +271 -0
- attune_llm/security/QUICK_REFERENCE.md +316 -0
- attune_llm/security/README.md +262 -0
- attune_llm/security/__init__.py +62 -0
- attune_llm/security/audit_logger.py +929 -0
- attune_llm/security/audit_logger_example.py +152 -0
- attune_llm/security/pii_scrubber.py +640 -0
- attune_llm/security/secrets_detector.py +678 -0
- attune_llm/security/secrets_detector_example.py +304 -0
- attune_llm/security/secure_memdocs.py +1192 -0
- attune_llm/security/secure_memdocs_example.py +278 -0
- attune_llm/session_status.py +745 -0
- attune_llm/state.py +246 -0
- attune_llm/utils/__init__.py +5 -0
- attune_llm/utils/tokens.py +349 -0
- attune_software/SOFTWARE_PLUGIN_README.md +57 -0
- attune_software/__init__.py +13 -0
- attune_software/cli/__init__.py +120 -0
- attune_software/cli/inspect.py +362 -0
- attune_software/cli.py +574 -0
- attune_software/plugin.py +188 -0
- workflow_scaffolding/__init__.py +11 -0
- workflow_scaffolding/__main__.py +12 -0
- workflow_scaffolding/cli.py +206 -0
- workflow_scaffolding/generator.py +265 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Integration example for hot-reload with workflow API.
|
|
2
|
+
|
|
3
|
+
Shows how to integrate hot-reload into the existing workflow_api.py.
|
|
4
|
+
|
|
5
|
+
Copyright 2025 Smart AI Memory, LLC
|
|
6
|
+
Licensed under Fair Source 0.9
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
13
|
+
|
|
14
|
+
from .config import get_hot_reload_config
|
|
15
|
+
from .reloader import WorkflowReloader
|
|
16
|
+
from .watcher import WorkflowFileWatcher
|
|
17
|
+
from .websocket import create_notification_callback, get_notification_manager
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HotReloadIntegration:
|
|
23
|
+
"""Integrates hot-reload with workflow API.
|
|
24
|
+
|
|
25
|
+
Example usage in workflow_api.py:
|
|
26
|
+
|
|
27
|
+
from hot_reload.integration import HotReloadIntegration
|
|
28
|
+
|
|
29
|
+
# Create FastAPI app
|
|
30
|
+
app = FastAPI()
|
|
31
|
+
|
|
32
|
+
# Initialize hot-reload (if enabled)
|
|
33
|
+
hot_reload = HotReloadIntegration(app, register_workflow)
|
|
34
|
+
|
|
35
|
+
@app.on_event("startup")
|
|
36
|
+
async def startup_event():
|
|
37
|
+
init_workflows() # Initialize workflows
|
|
38
|
+
hot_reload.start() # Start hot-reload watcher
|
|
39
|
+
|
|
40
|
+
@app.on_event("shutdown")
|
|
41
|
+
async def shutdown_event():
|
|
42
|
+
hot_reload.stop()
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
app: FastAPI,
|
|
49
|
+
register_callback: Callable[[str, type], bool],
|
|
50
|
+
):
|
|
51
|
+
"""Initialize hot-reload integration.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
app: FastAPI application instance
|
|
55
|
+
register_callback: Function to register workflow (workflow_id, workflow_class) -> bool
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
self.app = app
|
|
59
|
+
self.register_callback = register_callback
|
|
60
|
+
self.config = get_hot_reload_config()
|
|
61
|
+
|
|
62
|
+
# Initialize components
|
|
63
|
+
self.notification_callback = create_notification_callback()
|
|
64
|
+
self.reloader = WorkflowReloader(
|
|
65
|
+
register_callback=self._register_workflow_wrapper,
|
|
66
|
+
notification_callback=self.notification_callback,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self.watcher: WorkflowFileWatcher | None = None
|
|
70
|
+
|
|
71
|
+
# Add WebSocket endpoint to app
|
|
72
|
+
if self.config.enabled:
|
|
73
|
+
self._setup_websocket_endpoint()
|
|
74
|
+
|
|
75
|
+
def _register_workflow_wrapper(self, workflow_id: str, workflow_class: type) -> bool:
|
|
76
|
+
"""Wrapper for register callback that handles errors.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
workflow_id: Workflow identifier
|
|
80
|
+
workflow_class: Workflow class to register
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if registration succeeded
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
return self.register_callback(workflow_id, workflow_class)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.error(f"Error registering workflow {workflow_id}: {e}")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def _setup_websocket_endpoint(self) -> None:
|
|
93
|
+
"""Add WebSocket endpoint to FastAPI app."""
|
|
94
|
+
|
|
95
|
+
@self.app.websocket(self.config.websocket_path)
|
|
96
|
+
async def hot_reload_websocket(websocket: WebSocket):
|
|
97
|
+
"""WebSocket endpoint for hot-reload notifications."""
|
|
98
|
+
manager = get_notification_manager()
|
|
99
|
+
|
|
100
|
+
await manager.connect(websocket)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Keep connection alive
|
|
104
|
+
while True:
|
|
105
|
+
# Receive ping messages
|
|
106
|
+
await websocket.receive_text()
|
|
107
|
+
|
|
108
|
+
except WebSocketDisconnect:
|
|
109
|
+
await manager.disconnect(websocket)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.error(f"WebSocket error: {e}")
|
|
112
|
+
await manager.disconnect(websocket)
|
|
113
|
+
|
|
114
|
+
logger.info(f"WebSocket endpoint added: {self.config.websocket_path}")
|
|
115
|
+
|
|
116
|
+
def start(self) -> None:
|
|
117
|
+
"""Start hot-reload watcher."""
|
|
118
|
+
if not self.config.enabled:
|
|
119
|
+
logger.info("Hot-reload disabled (set HOT_RELOAD_ENABLED=true to enable)")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
if not self.config.watch_dirs:
|
|
123
|
+
logger.warning("No workflow directories found to watch")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if self.watcher and self.watcher.is_running():
|
|
127
|
+
logger.warning("Hot-reload already started")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Create watcher
|
|
131
|
+
self.watcher = WorkflowFileWatcher(
|
|
132
|
+
workflow_dirs=self.config.watch_dirs,
|
|
133
|
+
reload_callback=self._on_file_change,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Start watching
|
|
137
|
+
self.watcher.start()
|
|
138
|
+
|
|
139
|
+
logger.info(f"🔥 Hot-reload started - watching {len(self.config.watch_dirs)} directories")
|
|
140
|
+
|
|
141
|
+
def stop(self) -> None:
|
|
142
|
+
"""Stop hot-reload watcher."""
|
|
143
|
+
if self.watcher:
|
|
144
|
+
self.watcher.stop()
|
|
145
|
+
self.watcher = None
|
|
146
|
+
logger.info("Hot-reload stopped")
|
|
147
|
+
|
|
148
|
+
def _on_file_change(self, workflow_id: str, file_path: str) -> None:
|
|
149
|
+
"""Handle file change event.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
workflow_id: ID of workflow that changed
|
|
153
|
+
file_path: Path to changed file
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
logger.info(f"File change detected: {workflow_id} ({file_path})")
|
|
157
|
+
|
|
158
|
+
# Reload workflow
|
|
159
|
+
result = self.reloader.reload_workflow(workflow_id, file_path)
|
|
160
|
+
|
|
161
|
+
if result.success:
|
|
162
|
+
logger.info(f"✓ {result.message}")
|
|
163
|
+
else:
|
|
164
|
+
logger.error(f"✗ Reload failed: {result.error}")
|
|
165
|
+
|
|
166
|
+
def get_status(self) -> dict:
|
|
167
|
+
"""Get hot-reload status.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Status dictionary
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
return {
|
|
174
|
+
"enabled": self.config.enabled,
|
|
175
|
+
"running": self.watcher.is_running() if self.watcher else False,
|
|
176
|
+
"watch_dirs": [str(d) for d in self.config.watch_dirs],
|
|
177
|
+
"reload_count": self.reloader.get_reload_count(),
|
|
178
|
+
"websocket_connections": get_notification_manager().get_connection_count(),
|
|
179
|
+
"websocket_path": self.config.websocket_path,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Example usage in workflow_api.py:
|
|
184
|
+
"""
|
|
185
|
+
from fastapi import FastAPI
|
|
186
|
+
from hot_reload.integration import HotReloadIntegration
|
|
187
|
+
|
|
188
|
+
app = FastAPI(title="Empathy Workflow API")
|
|
189
|
+
|
|
190
|
+
# Global hot-reload instance
|
|
191
|
+
hot_reload = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def register_workflow(workflow_id: str, workflow_class: type, *args, **kwargs) -> bool:
|
|
195
|
+
'''Register workflow with WORKFLOWS dict'''
|
|
196
|
+
try:
|
|
197
|
+
WORKFLOWS[workflow_id] = workflow_class(*args, **kwargs)
|
|
198
|
+
logger.info(f"✓ Registered workflow: {workflow_id}")
|
|
199
|
+
return True
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.error(f"Failed to register {workflow_id}: {e}")
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.on_event("startup")
|
|
206
|
+
async def startup_event():
|
|
207
|
+
global hot_reload
|
|
208
|
+
|
|
209
|
+
# Initialize workflows
|
|
210
|
+
init_workflows()
|
|
211
|
+
|
|
212
|
+
# Start hot-reload
|
|
213
|
+
hot_reload = HotReloadIntegration(app, register_workflow)
|
|
214
|
+
hot_reload.start()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.on_event("shutdown")
|
|
218
|
+
async def shutdown_event():
|
|
219
|
+
if hot_reload:
|
|
220
|
+
hot_reload.stop()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.get("/api/hot-reload/status")
|
|
224
|
+
async def get_hot_reload_status():
|
|
225
|
+
'''Get hot-reload status'''
|
|
226
|
+
if not hot_reload:
|
|
227
|
+
return {"enabled": False}
|
|
228
|
+
return hot_reload.get_status()
|
|
229
|
+
"""
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Dynamic workflow reloader for hot-reload.
|
|
2
|
+
|
|
3
|
+
Handles reloading workflow modules without server restart.
|
|
4
|
+
|
|
5
|
+
Copyright 2025 Smart AI Memory, LLC
|
|
6
|
+
Licensed under Fair Source 0.9
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReloadResult:
|
|
20
|
+
"""Result of a workflow reload operation."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
success: bool,
|
|
25
|
+
workflow_id: str,
|
|
26
|
+
message: str,
|
|
27
|
+
error: str | None = None,
|
|
28
|
+
):
|
|
29
|
+
"""Initialize reload result.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
success: Whether reload succeeded
|
|
33
|
+
workflow_id: ID of workflow that was reloaded
|
|
34
|
+
message: Status message
|
|
35
|
+
error: Error message if failed
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
self.success = success
|
|
39
|
+
self.workflow_id = workflow_id
|
|
40
|
+
self.message = message
|
|
41
|
+
self.error = error
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
"""Convert to dictionary."""
|
|
45
|
+
return {
|
|
46
|
+
"success": self.success,
|
|
47
|
+
"workflow_id": self.workflow_id,
|
|
48
|
+
"message": self.message,
|
|
49
|
+
"error": self.error,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WorkflowReloader:
|
|
54
|
+
"""Handles dynamic reloading of workflow modules.
|
|
55
|
+
|
|
56
|
+
Supports hot-reload of workflows without server restart by:
|
|
57
|
+
1. Unloading old module from sys.modules
|
|
58
|
+
2. Reloading module with importlib
|
|
59
|
+
3. Re-registering workflow with workflow API
|
|
60
|
+
4. Notifying clients via callback
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
register_callback: Callable[[str, type], bool],
|
|
66
|
+
notification_callback: Callable[[dict], None] | None = None,
|
|
67
|
+
):
|
|
68
|
+
"""Initialize reloader.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
register_callback: Function to register workflow (workflow_id, workflow_class) -> success
|
|
72
|
+
notification_callback: Optional function to notify clients of reload events
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
self.register_callback = register_callback
|
|
76
|
+
self.notification_callback = notification_callback
|
|
77
|
+
self._reload_count = 0
|
|
78
|
+
|
|
79
|
+
def reload_workflow(self, workflow_id: str, file_path: str) -> ReloadResult:
|
|
80
|
+
"""Reload a workflow module.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
workflow_id: Workflow identifier
|
|
84
|
+
file_path: Path to workflow file
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
ReloadResult with outcome
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
logger.info(f"Attempting to reload workflow: {workflow_id} from {file_path}")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# Get module name from file path
|
|
94
|
+
module_name = self._get_module_name(file_path)
|
|
95
|
+
if not module_name:
|
|
96
|
+
error_msg = f"Could not determine module name from {file_path}"
|
|
97
|
+
logger.error(error_msg)
|
|
98
|
+
return ReloadResult(
|
|
99
|
+
success=False,
|
|
100
|
+
workflow_id=workflow_id,
|
|
101
|
+
message="Failed to reload",
|
|
102
|
+
error=error_msg,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Unload old module
|
|
106
|
+
self._unload_module(module_name)
|
|
107
|
+
|
|
108
|
+
# Reload module
|
|
109
|
+
try:
|
|
110
|
+
module = importlib.import_module(module_name)
|
|
111
|
+
except ImportError as e:
|
|
112
|
+
error_msg = f"Failed to import module {module_name}: {e}"
|
|
113
|
+
logger.error(error_msg)
|
|
114
|
+
self._notify_reload_failed(workflow_id, error_msg)
|
|
115
|
+
return ReloadResult(
|
|
116
|
+
success=False,
|
|
117
|
+
workflow_id=workflow_id,
|
|
118
|
+
message="Import failed",
|
|
119
|
+
error=error_msg,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Find workflow class in module
|
|
123
|
+
workflow_class = self._find_workflow_class(module)
|
|
124
|
+
if not workflow_class:
|
|
125
|
+
error_msg = f"No workflow class found in {module_name}"
|
|
126
|
+
logger.error(error_msg)
|
|
127
|
+
self._notify_reload_failed(workflow_id, error_msg)
|
|
128
|
+
return ReloadResult(
|
|
129
|
+
success=False,
|
|
130
|
+
workflow_id=workflow_id,
|
|
131
|
+
message="No workflow class found",
|
|
132
|
+
error=error_msg,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Re-register workflow
|
|
136
|
+
success = self.register_callback(workflow_id, workflow_class)
|
|
137
|
+
|
|
138
|
+
if success:
|
|
139
|
+
self._reload_count += 1
|
|
140
|
+
logger.info(
|
|
141
|
+
f"✓ Successfully reloaded {workflow_id} ({self._reload_count} total reloads)"
|
|
142
|
+
)
|
|
143
|
+
self._notify_reload_success(workflow_id)
|
|
144
|
+
|
|
145
|
+
return ReloadResult(
|
|
146
|
+
success=True,
|
|
147
|
+
workflow_id=workflow_id,
|
|
148
|
+
message=f"Reloaded successfully (reload #{self._reload_count})",
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
error_msg = "Registration failed"
|
|
152
|
+
logger.error(f"Failed to re-register {workflow_id}")
|
|
153
|
+
self._notify_reload_failed(workflow_id, error_msg)
|
|
154
|
+
return ReloadResult(
|
|
155
|
+
success=False,
|
|
156
|
+
workflow_id=workflow_id,
|
|
157
|
+
message="Registration failed",
|
|
158
|
+
error=error_msg,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
error_msg = f"Unexpected error reloading {workflow_id}: {e}"
|
|
163
|
+
logger.exception(error_msg)
|
|
164
|
+
self._notify_reload_failed(workflow_id, str(e))
|
|
165
|
+
return ReloadResult(
|
|
166
|
+
success=False,
|
|
167
|
+
workflow_id=workflow_id,
|
|
168
|
+
message="Unexpected error",
|
|
169
|
+
error=str(e),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _get_module_name(self, file_path: str) -> str | None:
|
|
173
|
+
"""Get Python module name from file path.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
file_path: Path to Python file
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Module name or None if cannot determine
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
path = Path(file_path).resolve()
|
|
184
|
+
|
|
185
|
+
# Remove .py extension
|
|
186
|
+
if not path.suffix == ".py":
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
# Get parts relative to project root
|
|
190
|
+
# Try to find common patterns: workflows/, empathy_software_plugin/workflows/
|
|
191
|
+
parts = path.parts
|
|
192
|
+
|
|
193
|
+
# Find workflow directory in path
|
|
194
|
+
workflow_dir_indices = [i for i, part in enumerate(parts) if "workflow" in part.lower()]
|
|
195
|
+
|
|
196
|
+
if not workflow_dir_indices:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
# Take from first workflow directory
|
|
200
|
+
start_idx = workflow_dir_indices[0]
|
|
201
|
+
|
|
202
|
+
# Build module name
|
|
203
|
+
module_parts = list(parts[start_idx:])
|
|
204
|
+
module_parts[-1] = module_parts[-1].replace(".py", "")
|
|
205
|
+
|
|
206
|
+
module_name = ".".join(module_parts)
|
|
207
|
+
return module_name
|
|
208
|
+
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.error(f"Error getting module name from {file_path}: {e}")
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def _unload_module(self, module_name: str) -> None:
|
|
214
|
+
"""Unload module from sys.modules.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
module_name: Name of module to unload
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
# Unload exact module
|
|
221
|
+
if module_name in sys.modules:
|
|
222
|
+
del sys.modules[module_name]
|
|
223
|
+
logger.debug(f"Unloaded module: {module_name}")
|
|
224
|
+
|
|
225
|
+
# Also unload any submodules
|
|
226
|
+
submodules = [name for name in sys.modules.keys() if name.startswith(f"{module_name}.")]
|
|
227
|
+
for submodule in submodules:
|
|
228
|
+
del sys.modules[submodule]
|
|
229
|
+
logger.debug(f"Unloaded submodule: {submodule}")
|
|
230
|
+
|
|
231
|
+
def _find_workflow_class(self, module: Any) -> type | None:
|
|
232
|
+
"""Find workflow class in module.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
module: Python module
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Workflow class or None if not found
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
# Look for classes ending with "Workflow"
|
|
242
|
+
for name in dir(module):
|
|
243
|
+
if name.endswith("Workflow") and not name.startswith("_"):
|
|
244
|
+
attr = getattr(module, name)
|
|
245
|
+
if isinstance(attr, type):
|
|
246
|
+
return attr
|
|
247
|
+
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
def _notify_reload_success(self, workflow_id: str) -> None:
|
|
251
|
+
"""Notify clients of successful reload.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
workflow_id: ID of reloaded workflow
|
|
255
|
+
|
|
256
|
+
"""
|
|
257
|
+
if self.notification_callback:
|
|
258
|
+
try:
|
|
259
|
+
self.notification_callback(
|
|
260
|
+
{
|
|
261
|
+
"event": "workflow_reloaded",
|
|
262
|
+
"workflow_id": workflow_id,
|
|
263
|
+
"success": True,
|
|
264
|
+
"reload_count": self._reload_count,
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.error(f"Error sending reload notification: {e}")
|
|
269
|
+
|
|
270
|
+
def _notify_reload_failed(self, workflow_id: str, error: str) -> None:
|
|
271
|
+
"""Notify clients of failed reload.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
workflow_id: ID of workflow that failed to reload
|
|
275
|
+
error: Error message
|
|
276
|
+
|
|
277
|
+
"""
|
|
278
|
+
if self.notification_callback:
|
|
279
|
+
try:
|
|
280
|
+
self.notification_callback(
|
|
281
|
+
{
|
|
282
|
+
"event": "workflow_reload_failed",
|
|
283
|
+
"workflow_id": workflow_id,
|
|
284
|
+
"success": False,
|
|
285
|
+
"error": error,
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
logger.error(f"Error sending failure notification: {e}")
|
|
290
|
+
|
|
291
|
+
def get_reload_count(self) -> int:
|
|
292
|
+
"""Get total number of successful reloads.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Reload count
|
|
296
|
+
|
|
297
|
+
"""
|
|
298
|
+
return self._reload_count
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""File system watcher for workflow hot-reload.
|
|
2
|
+
|
|
3
|
+
Monitors workflow directories for changes and triggers reloads.
|
|
4
|
+
|
|
5
|
+
Copyright 2025 Smart AI Memory, LLC
|
|
6
|
+
Licensed under Fair Source 0.9
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
14
|
+
from watchdog.observers import Observer
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WorkflowFileHandler(FileSystemEventHandler):
|
|
20
|
+
"""Handles file system events for workflow files."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, reload_callback: Callable[[str, str], None]):
|
|
23
|
+
"""Initialize handler.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
reload_callback: Function to call when workflow file changes (workflow_id, file_path)
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.reload_callback = reload_callback
|
|
31
|
+
self._processing: set[str] = set() # Prevent duplicate events
|
|
32
|
+
|
|
33
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
34
|
+
"""Handle file modification events.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
event: File system event
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
if event.is_directory:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
# Convert file_path to str if it's bytes
|
|
44
|
+
file_path_raw = event.src_path
|
|
45
|
+
file_path = (
|
|
46
|
+
file_path_raw.decode("utf-8") if isinstance(file_path_raw, bytes) else file_path_raw
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Only process Python files
|
|
50
|
+
if not file_path.endswith(".py"):
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Skip __pycache__ and test files
|
|
54
|
+
if "__pycache__" in file_path or "test_" in file_path:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Prevent duplicate processing
|
|
58
|
+
if file_path in self._processing:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
self._processing.add(file_path)
|
|
63
|
+
|
|
64
|
+
workflow_id = self._extract_workflow_id(file_path)
|
|
65
|
+
if workflow_id:
|
|
66
|
+
logger.info(f"Detected change in {workflow_id} ({file_path})")
|
|
67
|
+
self.reload_callback(workflow_id, file_path)
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.error(f"Error processing file change {file_path}: {e}")
|
|
71
|
+
finally:
|
|
72
|
+
self._processing.discard(file_path)
|
|
73
|
+
|
|
74
|
+
def _extract_workflow_id(self, file_path: str) -> str | None:
|
|
75
|
+
"""Extract workflow ID from file path.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
file_path: Path to workflow file
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Workflow ID or None if cannot extract
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
path = Path(file_path)
|
|
85
|
+
|
|
86
|
+
# Get filename without extension
|
|
87
|
+
filename = path.stem
|
|
88
|
+
|
|
89
|
+
# Remove common suffixes
|
|
90
|
+
workflow_id = filename.replace("_workflow", "").replace("workflow_", "")
|
|
91
|
+
|
|
92
|
+
# Convert to workflow ID format (snake_case)
|
|
93
|
+
workflow_id = workflow_id.lower()
|
|
94
|
+
|
|
95
|
+
return workflow_id if workflow_id else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class WorkflowFileWatcher:
|
|
99
|
+
"""Watches workflow directories for file changes.
|
|
100
|
+
|
|
101
|
+
Monitors specified directories and triggers reload callbacks
|
|
102
|
+
when workflow files are modified.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, workflow_dirs: list[Path], reload_callback: Callable[[str, str], None]):
|
|
106
|
+
"""Initialize watcher.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
workflow_dirs: List of directories to watch
|
|
110
|
+
reload_callback: Function to call on file changes (workflow_id, file_path)
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
self.workflow_dirs = [Path(d) for d in workflow_dirs]
|
|
114
|
+
self.reload_callback = reload_callback
|
|
115
|
+
self.observer = Observer()
|
|
116
|
+
self.event_handler = WorkflowFileHandler(reload_callback)
|
|
117
|
+
self._running = False
|
|
118
|
+
|
|
119
|
+
def start(self) -> None:
|
|
120
|
+
"""Start watching workflow directories."""
|
|
121
|
+
if self._running:
|
|
122
|
+
logger.warning("Watcher already running")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
valid_dirs = []
|
|
126
|
+
for directory in self.workflow_dirs:
|
|
127
|
+
if not directory.exists():
|
|
128
|
+
logger.warning(f"Directory does not exist: {directory}")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if not directory.is_dir():
|
|
132
|
+
logger.warning(f"Not a directory: {directory}")
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# Schedule watching
|
|
136
|
+
self.observer.schedule(
|
|
137
|
+
self.event_handler,
|
|
138
|
+
str(directory),
|
|
139
|
+
recursive=True,
|
|
140
|
+
)
|
|
141
|
+
valid_dirs.append(directory)
|
|
142
|
+
logger.info(f"Watching directory: {directory}")
|
|
143
|
+
|
|
144
|
+
if not valid_dirs:
|
|
145
|
+
logger.error("No valid directories to watch")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
self.observer.start()
|
|
149
|
+
self._running = True
|
|
150
|
+
|
|
151
|
+
logger.info(
|
|
152
|
+
f"Hot-reload enabled for {len(valid_dirs)} "
|
|
153
|
+
f"{'directory' if len(valid_dirs) == 1 else 'directories'}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def stop(self) -> None:
|
|
157
|
+
"""Stop watching workflow directories."""
|
|
158
|
+
if not self._running:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
self.observer.stop()
|
|
162
|
+
self.observer.join(timeout=5.0)
|
|
163
|
+
self._running = False
|
|
164
|
+
|
|
165
|
+
logger.info("Hot-reload watcher stopped")
|
|
166
|
+
|
|
167
|
+
def is_running(self) -> bool:
|
|
168
|
+
"""Check if watcher is running.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
True if watching, False otherwise
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
return self._running
|
|
175
|
+
|
|
176
|
+
def __enter__(self):
|
|
177
|
+
"""Context manager entry."""
|
|
178
|
+
self.start()
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
182
|
+
"""Context manager exit."""
|
|
183
|
+
self.stop()
|