devsper 2.1.6__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.
- devsper/__init__.py +14 -0
- devsper/agents/a2a/__init__.py +27 -0
- devsper/agents/a2a/client.py +126 -0
- devsper/agents/a2a/discovery.py +24 -0
- devsper/agents/a2a/server.py +128 -0
- devsper/agents/a2a/tool_adapter.py +68 -0
- devsper/agents/a2a/types.py +49 -0
- devsper/agents/agent.py +602 -0
- devsper/agents/critic.py +80 -0
- devsper/agents/message_bus.py +124 -0
- devsper/agents/roles.py +181 -0
- devsper/agents/run_agent.py +78 -0
- devsper/analytics/__init__.py +5 -0
- devsper/analytics/tool_analytics.py +78 -0
- devsper/audit/__init__.py +5 -0
- devsper/audit/logger.py +214 -0
- devsper/bus/__init__.py +29 -0
- devsper/bus/backends/__init__.py +5 -0
- devsper/bus/backends/base.py +38 -0
- devsper/bus/backends/memory.py +55 -0
- devsper/bus/backends/redis.py +146 -0
- devsper/bus/message.py +56 -0
- devsper/bus/schema_version.py +3 -0
- devsper/bus/topics.py +19 -0
- devsper/cache/__init__.py +6 -0
- devsper/cache/embedding_index.py +98 -0
- devsper/cache/hashing.py +24 -0
- devsper/cache/store.py +153 -0
- devsper/cache/task_cache.py +191 -0
- devsper/cli/__init__.py +6 -0
- devsper/cli/commands/reg.py +733 -0
- devsper/cli/github_oauth.py +157 -0
- devsper/cli/init.py +637 -0
- devsper/cli/main.py +2956 -0
- devsper/cli/run_progress.py +103 -0
- devsper/cli/ui/__init__.py +65 -0
- devsper/cli/ui/components.py +94 -0
- devsper/cli/ui/errors.py +104 -0
- devsper/cli/ui/logging.py +120 -0
- devsper/cli/ui/onboarding.py +102 -0
- devsper/cli/ui/progress.py +43 -0
- devsper/cli/ui/run_view.py +308 -0
- devsper/cli/ui/theme.py +40 -0
- devsper/cluster/__init__.py +29 -0
- devsper/cluster/election.py +84 -0
- devsper/cluster/local.py +97 -0
- devsper/cluster/node_info.py +77 -0
- devsper/cluster/registry.py +71 -0
- devsper/cluster/router.py +117 -0
- devsper/cluster/state_backend.py +105 -0
- devsper/compliance/__init__.py +5 -0
- devsper/compliance/pii.py +147 -0
- devsper/config/__init__.py +52 -0
- devsper/config/config_loader.py +121 -0
- devsper/config/defaults.py +77 -0
- devsper/config/resolver.py +342 -0
- devsper/config/schema.py +237 -0
- devsper/credentials/__init__.py +19 -0
- devsper/credentials/cli.py +197 -0
- devsper/credentials/migration.py +124 -0
- devsper/credentials/store.py +142 -0
- devsper/dashboard/__init__.py +9 -0
- devsper/dashboard/dashboard.py +87 -0
- devsper/dev/__init__.py +25 -0
- devsper/dev/builder.py +195 -0
- devsper/dev/debugger.py +95 -0
- devsper/dev/repo_index.py +138 -0
- devsper/dev/sandbox.py +203 -0
- devsper/dev/scaffold.py +122 -0
- devsper/embeddings/__init__.py +5 -0
- devsper/embeddings/service.py +36 -0
- devsper/explainability/__init__.py +14 -0
- devsper/explainability/decision_tree.py +104 -0
- devsper/explainability/rationale.py +38 -0
- devsper/explainability/simulation.py +56 -0
- devsper/hitl/__init__.py +13 -0
- devsper/hitl/approval.py +160 -0
- devsper/hitl/escalation.py +95 -0
- devsper/intelligence/__init__.py +9 -0
- devsper/intelligence/adaptation.py +88 -0
- devsper/intelligence/analysis/__init__.py +19 -0
- devsper/intelligence/analysis/analyzer.py +71 -0
- devsper/intelligence/analysis/cost_estimator.py +66 -0
- devsper/intelligence/analysis/formatter.py +103 -0
- devsper/intelligence/analysis/run_report.py +402 -0
- devsper/intelligence/learning_engine.py +92 -0
- devsper/intelligence/strategies/__init__.py +23 -0
- devsper/intelligence/strategies/base.py +14 -0
- devsper/intelligence/strategies/code_analysis_strategy.py +33 -0
- devsper/intelligence/strategies/data_science_strategy.py +33 -0
- devsper/intelligence/strategies/document_pipeline_strategy.py +33 -0
- devsper/intelligence/strategies/experiment_strategy.py +33 -0
- devsper/intelligence/strategies/research_strategy.py +34 -0
- devsper/intelligence/strategy_selector.py +84 -0
- devsper/intelligence/synthesis.py +132 -0
- devsper/intelligence/task_optimizer.py +92 -0
- devsper/knowledge/__init__.py +5 -0
- devsper/knowledge/extractor.py +204 -0
- devsper/knowledge/knowledge_graph.py +184 -0
- devsper/knowledge/query.py +285 -0
- devsper/memory/__init__.py +35 -0
- devsper/memory/consolidation.py +138 -0
- devsper/memory/embeddings.py +60 -0
- devsper/memory/memory_index.py +97 -0
- devsper/memory/memory_router.py +62 -0
- devsper/memory/memory_store.py +221 -0
- devsper/memory/memory_types.py +54 -0
- devsper/memory/namespaces.py +45 -0
- devsper/memory/scoring.py +77 -0
- devsper/memory/summarizer.py +52 -0
- devsper/nodes/__init__.py +5 -0
- devsper/nodes/controller.py +449 -0
- devsper/nodes/rpc.py +127 -0
- devsper/nodes/single.py +161 -0
- devsper/nodes/worker.py +506 -0
- devsper/orchestration/__init__.py +19 -0
- devsper/orchestration/meta_planner.py +239 -0
- devsper/orchestration/priority_queue.py +61 -0
- devsper/plugins/__init__.py +19 -0
- devsper/plugins/marketplace/__init__.py +0 -0
- devsper/plugins/plugin_loader.py +70 -0
- devsper/plugins/plugin_registry.py +34 -0
- devsper/plugins/registry.py +83 -0
- devsper/protocols/__init__.py +6 -0
- devsper/providers/__init__.py +17 -0
- devsper/providers/anthropic.py +84 -0
- devsper/providers/base.py +75 -0
- devsper/providers/complexity_router.py +94 -0
- devsper/providers/gemini.py +36 -0
- devsper/providers/github.py +180 -0
- devsper/providers/model_router.py +40 -0
- devsper/providers/openai.py +105 -0
- devsper/providers/router/__init__.py +21 -0
- devsper/providers/router/backends/__init__.py +19 -0
- devsper/providers/router/backends/anthropic_backend.py +111 -0
- devsper/providers/router/backends/custom_backend.py +138 -0
- devsper/providers/router/backends/gemini_backend.py +89 -0
- devsper/providers/router/backends/github_backend.py +165 -0
- devsper/providers/router/backends/ollama_backend.py +104 -0
- devsper/providers/router/backends/openai_backend.py +142 -0
- devsper/providers/router/backends/vllm_backend.py +35 -0
- devsper/providers/router/base.py +60 -0
- devsper/providers/router/factory.py +92 -0
- devsper/providers/router/legacy.py +101 -0
- devsper/providers/router/router.py +135 -0
- devsper/reasoning/__init__.py +12 -0
- devsper/reasoning/graph.py +59 -0
- devsper/reasoning/nodes.py +20 -0
- devsper/reasoning/store.py +67 -0
- devsper/runtime/__init__.py +12 -0
- devsper/runtime/health.py +88 -0
- devsper/runtime/replay.py +53 -0
- devsper/runtime/replay_engine.py +142 -0
- devsper/runtime/run_history.py +204 -0
- devsper/runtime/telemetry.py +116 -0
- devsper/runtime/visualize.py +58 -0
- devsper/sandbox/__init__.py +13 -0
- devsper/sandbox/sandbox.py +161 -0
- devsper/swarm/checkpointer.py +65 -0
- devsper/swarm/executor.py +558 -0
- devsper/swarm/map_reduce.py +44 -0
- devsper/swarm/planner.py +197 -0
- devsper/swarm/prefetcher.py +91 -0
- devsper/swarm/scheduler.py +153 -0
- devsper/swarm/speculation.py +47 -0
- devsper/swarm/swarm.py +562 -0
- devsper/tools/__init__.py +33 -0
- devsper/tools/base.py +29 -0
- devsper/tools/code_intelligence/__init__.py +13 -0
- devsper/tools/code_intelligence/api_surface_extractor.py +73 -0
- devsper/tools/code_intelligence/architecture_analyzer.py +65 -0
- devsper/tools/code_intelligence/codebase_indexer.py +71 -0
- devsper/tools/code_intelligence/dependency_graph_builder.py +67 -0
- devsper/tools/code_intelligence/design_pattern_detector.py +62 -0
- devsper/tools/code_intelligence/large_function_detector.py +68 -0
- devsper/tools/code_intelligence/module_responsibility_mapper.py +56 -0
- devsper/tools/code_intelligence/parallel_codebase_analysis.py +44 -0
- devsper/tools/code_intelligence/refactor_candidate_detector.py +81 -0
- devsper/tools/code_intelligence/repository_semantic_index.py +61 -0
- devsper/tools/code_intelligence/test_coverage_estimator.py +62 -0
- devsper/tools/coding/__init__.py +12 -0
- devsper/tools/coding/analyze_code_complexity.py +48 -0
- devsper/tools/coding/dependency_analyzer.py +42 -0
- devsper/tools/coding/extract_functions.py +38 -0
- devsper/tools/coding/format_python.py +50 -0
- devsper/tools/coding/generate_docstrings.py +40 -0
- devsper/tools/coding/generate_unit_tests.py +42 -0
- devsper/tools/coding/lint_python.py +51 -0
- devsper/tools/coding/refactor_function.py +41 -0
- devsper/tools/coding/repo_structure_map.py +54 -0
- devsper/tools/coding/run_python.py +53 -0
- devsper/tools/data/__init__.py +12 -0
- devsper/tools/data/column_type_detection.py +64 -0
- devsper/tools/data/csv_summary.py +52 -0
- devsper/tools/data/dataframe_filter.py +51 -0
- devsper/tools/data/dataframe_groupby.py +47 -0
- devsper/tools/data/dataframe_stats.py +38 -0
- devsper/tools/data/dataset_sampling.py +55 -0
- devsper/tools/data/dataset_schema.py +45 -0
- devsper/tools/data/json_pretty_print.py +37 -0
- devsper/tools/data/json_query.py +46 -0
- devsper/tools/data/missing_value_report.py +47 -0
- devsper/tools/data_science/__init__.py +13 -0
- devsper/tools/data_science/correlation_heatmap.py +72 -0
- devsper/tools/data_science/dataset_bias_detector.py +49 -0
- devsper/tools/data_science/dataset_distribution_report.py +64 -0
- devsper/tools/data_science/dataset_drift_detector.py +64 -0
- devsper/tools/data_science/dataset_outlier_detector.py +65 -0
- devsper/tools/data_science/dataset_profile.py +76 -0
- devsper/tools/data_science/distributed_dataset_processor.py +54 -0
- devsper/tools/data_science/feature_engineering_suggestions.py +69 -0
- devsper/tools/data_science/feature_importance_estimator.py +82 -0
- devsper/tools/data_science/model_input_validator.py +59 -0
- devsper/tools/data_science/time_series_analyzer.py +57 -0
- devsper/tools/documents/__init__.py +11 -0
- devsper/tools/documents/_docproc.py +56 -0
- devsper/tools/documents/document_to_markdown.py +29 -0
- devsper/tools/documents/extract_document_images.py +39 -0
- devsper/tools/documents/extract_document_text.py +29 -0
- devsper/tools/documents/extract_equations.py +36 -0
- devsper/tools/documents/extract_tables.py +47 -0
- devsper/tools/documents/summarize_document.py +42 -0
- devsper/tools/documents/write_latex_document.py +133 -0
- devsper/tools/documents/write_markdown_document.py +89 -0
- devsper/tools/documents/write_word_document.py +149 -0
- devsper/tools/experiments/__init__.py +13 -0
- devsper/tools/experiments/bootstrap_estimator.py +54 -0
- devsper/tools/experiments/experiment_report_generator.py +50 -0
- devsper/tools/experiments/experiment_tracker.py +36 -0
- devsper/tools/experiments/grid_search_runner.py +50 -0
- devsper/tools/experiments/model_benchmark_runner.py +45 -0
- devsper/tools/experiments/monte_carlo_experiment.py +38 -0
- devsper/tools/experiments/parameter_sweep_runner.py +51 -0
- devsper/tools/experiments/result_comparator.py +58 -0
- devsper/tools/experiments/simulation_runner.py +43 -0
- devsper/tools/experiments/statistical_significance_test.py +56 -0
- devsper/tools/experiments/swarm_map_reduce.py +42 -0
- devsper/tools/filesystem/__init__.py +12 -0
- devsper/tools/filesystem/append_file.py +42 -0
- devsper/tools/filesystem/file_hash.py +40 -0
- devsper/tools/filesystem/file_line_count.py +36 -0
- devsper/tools/filesystem/file_metadata.py +38 -0
- devsper/tools/filesystem/file_preview.py +55 -0
- devsper/tools/filesystem/find_large_files.py +50 -0
- devsper/tools/filesystem/list_directory.py +39 -0
- devsper/tools/filesystem/read_file.py +35 -0
- devsper/tools/filesystem/search_files.py +60 -0
- devsper/tools/filesystem/write_file.py +41 -0
- devsper/tools/flagship/__init__.py +15 -0
- devsper/tools/flagship/distributed_document_analysis.py +77 -0
- devsper/tools/flagship/docproc_corpus_pipeline.py +91 -0
- devsper/tools/flagship/repository_semantic_map.py +99 -0
- devsper/tools/flagship/research_graph_builder.py +111 -0
- devsper/tools/flagship/swarm_experiment_runner.py +86 -0
- devsper/tools/knowledge/__init__.py +10 -0
- devsper/tools/knowledge/citation_graph_builder.py +69 -0
- devsper/tools/knowledge/concept_frequency_analyzer.py +74 -0
- devsper/tools/knowledge/corpus_builder.py +66 -0
- devsper/tools/knowledge/cross_document_entity_linker.py +71 -0
- devsper/tools/knowledge/document_corpus_summary.py +68 -0
- devsper/tools/knowledge/document_topic_extractor.py +58 -0
- devsper/tools/knowledge/knowledge_graph_extractor.py +58 -0
- devsper/tools/knowledge/timeline_extractor.py +59 -0
- devsper/tools/math/__init__.py +12 -0
- devsper/tools/math/calculate_expression.py +52 -0
- devsper/tools/math/correlation.py +44 -0
- devsper/tools/math/distribution_summary.py +39 -0
- devsper/tools/math/histogram.py +53 -0
- devsper/tools/math/linear_regression.py +47 -0
- devsper/tools/math/matrix_multiply.py +38 -0
- devsper/tools/math/mean_std.py +35 -0
- devsper/tools/math/monte_carlo_simulation.py +43 -0
- devsper/tools/math/polynomial_fit.py +40 -0
- devsper/tools/math/random_sample.py +36 -0
- devsper/tools/mcp/__init__.py +23 -0
- devsper/tools/mcp/adapter.py +53 -0
- devsper/tools/mcp/client.py +235 -0
- devsper/tools/mcp/discovery.py +53 -0
- devsper/tools/memory/__init__.py +16 -0
- devsper/tools/memory/delete_memory.py +25 -0
- devsper/tools/memory/list_memory.py +34 -0
- devsper/tools/memory/search_memory.py +36 -0
- devsper/tools/memory/store_memory.py +47 -0
- devsper/tools/memory/summarize_memory.py +41 -0
- devsper/tools/memory/tag_memory.py +47 -0
- devsper/tools/pipelines.py +92 -0
- devsper/tools/registry.py +39 -0
- devsper/tools/research/__init__.py +12 -0
- devsper/tools/research/arxiv_download.py +55 -0
- devsper/tools/research/arxiv_search.py +58 -0
- devsper/tools/research/citation_extractor.py +35 -0
- devsper/tools/research/duckduckgo_search.py +42 -0
- devsper/tools/research/paper_metadata_extractor.py +45 -0
- devsper/tools/research/paper_summarizer.py +41 -0
- devsper/tools/research/research_question_generator.py +39 -0
- devsper/tools/research/topic_cluster.py +46 -0
- devsper/tools/research/web_search.py +47 -0
- devsper/tools/research/wikipedia_lookup.py +50 -0
- devsper/tools/research_advanced/__init__.py +14 -0
- devsper/tools/research_advanced/citation_context_extractor.py +60 -0
- devsper/tools/research_advanced/literature_review_generator.py +79 -0
- devsper/tools/research_advanced/methodology_extractor.py +58 -0
- devsper/tools/research_advanced/paper_contribution_extractor.py +50 -0
- devsper/tools/research_advanced/paper_dataset_identifier.py +49 -0
- devsper/tools/research_advanced/paper_method_comparator.py +62 -0
- devsper/tools/research_advanced/paper_similarity_search.py +69 -0
- devsper/tools/research_advanced/paper_trend_analyzer.py +69 -0
- devsper/tools/research_advanced/parallel_document_analyzer.py +56 -0
- devsper/tools/research_advanced/research_gap_finder.py +71 -0
- devsper/tools/research_advanced/research_topic_mapper.py +69 -0
- devsper/tools/research_advanced/swarm_literature_review.py +58 -0
- devsper/tools/scoring/__init__.py +52 -0
- devsper/tools/scoring/report.py +44 -0
- devsper/tools/scoring/scorer.py +39 -0
- devsper/tools/scoring/selector.py +61 -0
- devsper/tools/scoring/store.py +267 -0
- devsper/tools/selector.py +130 -0
- devsper/tools/system/__init__.py +12 -0
- devsper/tools/system/cpu_usage.py +22 -0
- devsper/tools/system/disk_usage.py +35 -0
- devsper/tools/system/environment_variables.py +29 -0
- devsper/tools/system/memory_usage.py +23 -0
- devsper/tools/system/pip_install.py +44 -0
- devsper/tools/system/pip_search.py +29 -0
- devsper/tools/system/process_list.py +34 -0
- devsper/tools/system/python_package_list.py +40 -0
- devsper/tools/system/run_shell_command.py +51 -0
- devsper/tools/system/system_info.py +26 -0
- devsper/tools/tool_runner.py +122 -0
- devsper/tui/__init__.py +5 -0
- devsper/tui/activity_feed_view.py +73 -0
- devsper/tui/adaptive_tasks_view.py +75 -0
- devsper/tui/agent_role_view.py +35 -0
- devsper/tui/app.py +395 -0
- devsper/tui/dashboard_screen.py +290 -0
- devsper/tui/dev_view.py +99 -0
- devsper/tui/inject_screen.py +73 -0
- devsper/tui/knowledge_graph_view.py +46 -0
- devsper/tui/layout.py +43 -0
- devsper/tui/logs_view.py +83 -0
- devsper/tui/memory_view.py +58 -0
- devsper/tui/performance_view.py +33 -0
- devsper/tui/reasoning_graph_view.py +39 -0
- devsper/tui/results_view.py +139 -0
- devsper/tui/swarm_view.py +37 -0
- devsper/tui/task_detail_screen.py +55 -0
- devsper/tui/task_view.py +103 -0
- devsper/types/event.py +97 -0
- devsper/types/exceptions.py +21 -0
- devsper/types/swarm.py +41 -0
- devsper/types/task.py +80 -0
- devsper/upgrade/__init__.py +21 -0
- devsper/upgrade/changelog.py +124 -0
- devsper/upgrade/cli.py +145 -0
- devsper/upgrade/installer.py +103 -0
- devsper/upgrade/notifier.py +52 -0
- devsper/upgrade/version_check.py +121 -0
- devsper/utils/event_logger.py +88 -0
- devsper/utils/http.py +43 -0
- devsper/utils/models.py +54 -0
- devsper/visualization/__init__.py +5 -0
- devsper/visualization/dag_export.py +67 -0
- devsper/workflow/__init__.py +18 -0
- devsper/workflow/conditions.py +157 -0
- devsper/workflow/context.py +108 -0
- devsper/workflow/loader.py +156 -0
- devsper/workflow/resolver.py +109 -0
- devsper/workflow/runner.py +562 -0
- devsper/workflow/schema.py +63 -0
- devsper/workflow/validator.py +128 -0
- devsper-2.1.6.dist-info/METADATA +346 -0
- devsper-2.1.6.dist-info/RECORD +375 -0
- devsper-2.1.6.dist-info/WHEEL +4 -0
- devsper-2.1.6.dist-info/entry_points.txt +3 -0
- devsper-2.1.6.dist-info/licenses/LICENSE +639 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live run view: real-time task table, tool activity, cost during devsper run.
|
|
3
|
+
Polls event log (or subscribes to bus when available). Rich Live, refresh 10 Hz.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from devsper.cli.ui.theme import console
|
|
14
|
+
from devsper.cli.ui.components import devsperHeader, TaskRow, RoleTag, CostDisplay, SectionHeader
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TaskState:
|
|
19
|
+
task_id: str
|
|
20
|
+
short_id: str
|
|
21
|
+
description: str
|
|
22
|
+
role: str
|
|
23
|
+
status: str # pending, running, completed, failed, cached, skipped
|
|
24
|
+
duration_ms: int | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RunViewState:
|
|
29
|
+
run_id: str
|
|
30
|
+
run_id_short: str
|
|
31
|
+
planner_message: str
|
|
32
|
+
planner_visible: bool
|
|
33
|
+
tasks: list[TaskState] = field(default_factory=list)
|
|
34
|
+
tool_counts: dict[str, int] = field(default_factory=dict)
|
|
35
|
+
total_cost_usd: float | None = None
|
|
36
|
+
worker_count: int = 0
|
|
37
|
+
started_at: float = field(default_factory=time.time)
|
|
38
|
+
finished: bool = False
|
|
39
|
+
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
40
|
+
|
|
41
|
+
def update_from_events(self, log_path: str | None) -> None:
|
|
42
|
+
if not log_path or not os.path.isfile(log_path):
|
|
43
|
+
return
|
|
44
|
+
events = []
|
|
45
|
+
try:
|
|
46
|
+
from devsper.types.event import Event
|
|
47
|
+
with open(log_path, "r", encoding="utf-8") as f:
|
|
48
|
+
for line in f:
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if not line:
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
events.append(Event.from_json(line))
|
|
54
|
+
except Exception:
|
|
55
|
+
try:
|
|
56
|
+
events.append(Event.model_validate_json(line))
|
|
57
|
+
except Exception:
|
|
58
|
+
continue
|
|
59
|
+
except Exception:
|
|
60
|
+
return
|
|
61
|
+
if not events:
|
|
62
|
+
return
|
|
63
|
+
with self._lock:
|
|
64
|
+
task_descriptions: dict[str, str] = {}
|
|
65
|
+
task_roles: dict[str, str] = {}
|
|
66
|
+
started: set[str] = set()
|
|
67
|
+
completed: set[str] = set()
|
|
68
|
+
failed: set[str] = set()
|
|
69
|
+
cached: set[str] = set()
|
|
70
|
+
task_duration_ms: dict[str, int] = {}
|
|
71
|
+
tool_counts: dict[str, int] = {}
|
|
72
|
+
planner_done = False
|
|
73
|
+
executor_done = False
|
|
74
|
+
for e in events:
|
|
75
|
+
payload = e.payload or {}
|
|
76
|
+
tid = (payload.get("task_id") or "").strip()
|
|
77
|
+
ev = getattr(e.type, "value", str(e.type))
|
|
78
|
+
if ev == "task_created" and tid:
|
|
79
|
+
task_descriptions[tid] = (payload.get("description") or "").strip()
|
|
80
|
+
task_roles[tid] = (payload.get("role") or "").strip()
|
|
81
|
+
elif ev in ("task_started", "agent_started") and tid:
|
|
82
|
+
started.add(tid)
|
|
83
|
+
elif ev == "task_completed" and tid:
|
|
84
|
+
completed.add(tid)
|
|
85
|
+
started.discard(tid)
|
|
86
|
+
dur = payload.get("duration_ms") or payload.get("duration_seconds")
|
|
87
|
+
if dur is not None:
|
|
88
|
+
task_duration_ms[tid] = int(dur) if isinstance(dur, (int, float)) else 0
|
|
89
|
+
elif ev == "task_failed" and tid:
|
|
90
|
+
failed.add(tid)
|
|
91
|
+
started.discard(tid)
|
|
92
|
+
elif ev in ("agent_finished") and tid and tid not in completed and tid not in failed:
|
|
93
|
+
completed.add(tid)
|
|
94
|
+
started.discard(tid)
|
|
95
|
+
elif ev == "task_cache_hit" and tid:
|
|
96
|
+
cached.add(tid)
|
|
97
|
+
completed.add(tid)
|
|
98
|
+
started.discard(tid)
|
|
99
|
+
elif ev == "tool_called":
|
|
100
|
+
name = (payload.get("tool") or payload.get("tool_name") or "tool").strip()
|
|
101
|
+
tool_counts[name] = tool_counts.get(name, 0) + 1
|
|
102
|
+
elif ev == "planner_finished":
|
|
103
|
+
planner_done = True
|
|
104
|
+
elif ev == "executor_finished":
|
|
105
|
+
executor_done = True
|
|
106
|
+
running_ids = started - completed - failed - cached
|
|
107
|
+
all_ids = sorted(set(task_descriptions) | running_ids | completed | failed | cached)
|
|
108
|
+
self.tasks = []
|
|
109
|
+
for tid in all_ids:
|
|
110
|
+
desc = task_descriptions.get(tid, "")
|
|
111
|
+
role = task_roles.get(tid, "")
|
|
112
|
+
short = tid[:8] if len(tid) >= 8 else tid
|
|
113
|
+
if tid in cached:
|
|
114
|
+
status = "cached"
|
|
115
|
+
elif tid in failed:
|
|
116
|
+
status = "failed"
|
|
117
|
+
elif tid in completed:
|
|
118
|
+
status = "completed"
|
|
119
|
+
elif tid in running_ids:
|
|
120
|
+
status = "running"
|
|
121
|
+
else:
|
|
122
|
+
status = "pending"
|
|
123
|
+
dur_ms = task_duration_ms.get(tid)
|
|
124
|
+
duration_str = f"{dur_ms}ms" if dur_ms is not None else ("..." if status == "running" else "—")
|
|
125
|
+
if dur_ms is not None and dur_ms >= 1000:
|
|
126
|
+
duration_str = f"{dur_ms/1000:.1f}s"
|
|
127
|
+
self.tasks.append(TaskState(
|
|
128
|
+
task_id=tid,
|
|
129
|
+
short_id=short,
|
|
130
|
+
description=desc,
|
|
131
|
+
role=role,
|
|
132
|
+
status=status,
|
|
133
|
+
duration_ms=dur_ms,
|
|
134
|
+
))
|
|
135
|
+
self.tool_counts = dict(sorted(tool_counts.items(), key=lambda x: -x[1])[:6])
|
|
136
|
+
self.planner_visible = not planner_done and not self.tasks
|
|
137
|
+
if planner_done and not self.tasks and all_ids:
|
|
138
|
+
self.planner_visible = False
|
|
139
|
+
if not self.planner_message or "Selecting" in self.planner_message:
|
|
140
|
+
last = events[-1] if events else None
|
|
141
|
+
if last:
|
|
142
|
+
ev_type = getattr(last.type, "value", str(last.type))
|
|
143
|
+
if ev_type == "swarm_started":
|
|
144
|
+
self.planner_message = "Selecting strategy..."
|
|
145
|
+
elif ev_type == "planner_started":
|
|
146
|
+
self.planner_message = "Decomposing task into subtasks..."
|
|
147
|
+
elif ev_type == "planner_finished":
|
|
148
|
+
self.planner_message = "Building execution DAG..."
|
|
149
|
+
else:
|
|
150
|
+
self.planner_message = "Querying knowledge graph..."
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _render_live_layout(state: RunViewState) -> object:
|
|
154
|
+
"""Build Rich renderable for current state."""
|
|
155
|
+
from rich.table import Table
|
|
156
|
+
from rich.text import Text
|
|
157
|
+
from rich.panel import Panel
|
|
158
|
+
from rich.columns import Columns
|
|
159
|
+
from rich.live import Group
|
|
160
|
+
|
|
161
|
+
# Header
|
|
162
|
+
version = ""
|
|
163
|
+
try:
|
|
164
|
+
import devsper
|
|
165
|
+
version = getattr(devsper, "__version__", "")
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
elapsed = int(time.time() - state.started_at)
|
|
169
|
+
time_str = f"{elapsed // 60}:{elapsed % 60:02d}"
|
|
170
|
+
header_left = devsperHeader(version=version, workers=state.worker_count or 0)
|
|
171
|
+
header_right = Text(f"run: {state.run_id_short} {time_str}", style="hive.muted")
|
|
172
|
+
header = Columns([header_left, header_right], expand=True)
|
|
173
|
+
header = Panel(header, border_style="hive.dim", padding=(0, 1))
|
|
174
|
+
|
|
175
|
+
# Planning phase
|
|
176
|
+
planning_line = Text()
|
|
177
|
+
if state.planner_visible:
|
|
178
|
+
planning_line = Text("◎ ", style="hive.primary") + Text(state.planner_message, style="dim")
|
|
179
|
+
else:
|
|
180
|
+
strategy = "research" # could come from events
|
|
181
|
+
n = len(state.tasks)
|
|
182
|
+
planning_line = Text(" strategy: ", style="hive.muted") + Text(strategy, style="hive.planner") + Text(f" · planning {n} subtasks", style="hive.muted")
|
|
183
|
+
planning_panel = Panel(planning_line, border_style="hive.dim", padding=(0, 1))
|
|
184
|
+
|
|
185
|
+
# Task table (max 12 rows) — one column per row with full task line
|
|
186
|
+
task_table = Table(show_header=False, box=None, padding=(0, 1))
|
|
187
|
+
task_table.add_column("task", width=80)
|
|
188
|
+
running_first = sorted(state.tasks, key=lambda t: (
|
|
189
|
+
0 if t.status == "running" else 1,
|
|
190
|
+
1 if t.status == "pending" else 0,
|
|
191
|
+
0 if t.status == "completed" else 1,
|
|
192
|
+
0 if t.status == "failed" else 1,
|
|
193
|
+
t.task_id,
|
|
194
|
+
))
|
|
195
|
+
for t in running_first[:12]:
|
|
196
|
+
dur_str = f"{t.duration_ms}ms" if t.duration_ms is not None else ("..." if t.status == "running" else "—")
|
|
197
|
+
if t.duration_ms is not None and t.duration_ms >= 1000:
|
|
198
|
+
dur_str = f"{t.duration_ms/1000:.1f}s"
|
|
199
|
+
tr = TaskRow(t.short_id, t.description, t.role, dur_str, t.status)
|
|
200
|
+
task_table.add_row(tr)
|
|
201
|
+
if len(state.tasks) > 12:
|
|
202
|
+
task_table.add_row(Text("+ " + str(len(state.tasks) - 12) + " more tasks", style="hive.muted"))
|
|
203
|
+
tasks_panel = Panel(Group(SectionHeader("Tasks"), task_table), border_style="hive.dim", padding=(0, 1))
|
|
204
|
+
|
|
205
|
+
# Tool strip
|
|
206
|
+
tool_parts = [Text(f" {name} ×{c}", style="hive.tool") for name, c in list(state.tool_counts.items())[:6]]
|
|
207
|
+
tool_line = Text(" ").join(tool_parts) if tool_parts else Text(" (no tools yet)", style="hive.dim")
|
|
208
|
+
tools_panel = Panel(Group(SectionHeader("Tools"), tool_line), border_style="hive.dim", padding=(0, 1))
|
|
209
|
+
|
|
210
|
+
# Status bar
|
|
211
|
+
done = sum(1 for t in state.tasks if t.status == "completed")
|
|
212
|
+
failed_n = sum(1 for t in state.tasks if t.status == "failed")
|
|
213
|
+
running_n = sum(1 for t in state.tasks if t.status == "running")
|
|
214
|
+
total = len(state.tasks)
|
|
215
|
+
cost_str = CostDisplay(state.total_cost_usd)
|
|
216
|
+
status_bar = Text()
|
|
217
|
+
status_bar.append_text(cost_str)
|
|
218
|
+
status_bar.append(Text(" · ", style="hive.muted"))
|
|
219
|
+
status_bar.append(Text(f"{state.worker_count} workers", style="hive.muted"))
|
|
220
|
+
status_bar.append(Text(" · ", style="hive.muted"))
|
|
221
|
+
status_bar.append(Text(f"{running_n} running", style="white"))
|
|
222
|
+
status_bar.append(Text(" · ", style="hive.muted"))
|
|
223
|
+
status_bar.append(Text(f"{done} done / {total} total", style="white"))
|
|
224
|
+
status_panel = Panel(status_bar, border_style="hive.dim", padding=(0, 1))
|
|
225
|
+
|
|
226
|
+
return Group(header, planning_panel, tasks_panel, tools_panel, status_panel)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def run_live_view(
|
|
230
|
+
log_path: str | None,
|
|
231
|
+
run_id: str,
|
|
232
|
+
worker_count: int,
|
|
233
|
+
poll_interval: float = 0.1,
|
|
234
|
+
stop_check: Callable[[], bool] | None = None,
|
|
235
|
+
) -> RunViewState:
|
|
236
|
+
"""Run the live view until stop_check() returns True (e.g. swarm thread finished). Returns final state."""
|
|
237
|
+
from rich.live import Live
|
|
238
|
+
state = RunViewState(
|
|
239
|
+
run_id=run_id,
|
|
240
|
+
run_id_short=(run_id or "")[:8],
|
|
241
|
+
planner_message="Selecting strategy...",
|
|
242
|
+
planner_visible=True,
|
|
243
|
+
worker_count=worker_count,
|
|
244
|
+
)
|
|
245
|
+
state.update_from_events(log_path)
|
|
246
|
+
|
|
247
|
+
def get_renderable() -> object:
|
|
248
|
+
state.update_from_events(log_path)
|
|
249
|
+
return _render_live_layout(state)
|
|
250
|
+
|
|
251
|
+
def is_finished() -> bool:
|
|
252
|
+
if stop_check is not None and stop_check():
|
|
253
|
+
return True
|
|
254
|
+
if not log_path or not os.path.isfile(log_path):
|
|
255
|
+
return False
|
|
256
|
+
try:
|
|
257
|
+
with open(log_path, "r", encoding="utf-8") as f:
|
|
258
|
+
lines = f.readlines()
|
|
259
|
+
for line in reversed(lines[-15:]):
|
|
260
|
+
line = line.strip()
|
|
261
|
+
if not line:
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
from devsper.types.event import Event
|
|
265
|
+
ev = Event.from_json(line)
|
|
266
|
+
except Exception:
|
|
267
|
+
try:
|
|
268
|
+
ev = Event.model_validate_json(line)
|
|
269
|
+
except Exception:
|
|
270
|
+
continue
|
|
271
|
+
if getattr(ev.type, "value", "") == "swarm_finished":
|
|
272
|
+
return True
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
with Live(get_renderable(), refresh_per_second=10, console=console) as live:
|
|
278
|
+
while not is_finished():
|
|
279
|
+
time.sleep(poll_interval)
|
|
280
|
+
live.update(get_renderable())
|
|
281
|
+
state.finished = True
|
|
282
|
+
state.update_from_events(log_path)
|
|
283
|
+
return state
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def print_run_summary(state: RunViewState, results: dict[str, str], summary_only: bool = False) -> None:
|
|
287
|
+
"""Print final summary panel and optionally task results."""
|
|
288
|
+
from rich.panel import Panel
|
|
289
|
+
from rich.text import Text
|
|
290
|
+
done = sum(1 for t in state.tasks if t.status == "completed")
|
|
291
|
+
failed_n = sum(1 for t in state.tasks if t.status == "failed")
|
|
292
|
+
skipped = sum(1 for t in state.tasks if t.status in ("skipped", "cached"))
|
|
293
|
+
total = len(state.tasks)
|
|
294
|
+
duration_s = time.time() - state.started_at
|
|
295
|
+
cost_str = f"${state.total_cost_usd:.4f}" if state.total_cost_usd is not None else "—"
|
|
296
|
+
cache_hits = sum(1 for t in state.tasks if t.status == "cached")
|
|
297
|
+
lines = [
|
|
298
|
+
f"{total} tasks · {done} completed · {failed_n} failed · {skipped} skipped",
|
|
299
|
+
f"Duration: {duration_s:.1f}s · Cost: {cost_str} · Cache hits: {cache_hits}",
|
|
300
|
+
f"run id: {state.run_id_short}",
|
|
301
|
+
]
|
|
302
|
+
console.print(Panel("\n".join(lines), title="Run complete", border_style="hive.success"))
|
|
303
|
+
if not summary_only and results:
|
|
304
|
+
for task_id, result in results.items():
|
|
305
|
+
console.print(Text(f"--- {task_id} ---", style="hive.primary"))
|
|
306
|
+
console.print((result or "")[:2000])
|
|
307
|
+
if (result or "") and len(result) > 2000:
|
|
308
|
+
console.print("...")
|
devsper/cli/ui/theme.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devsper CLI theme: sharp, dark-terminal-native, information-dense.
|
|
3
|
+
All CLI code imports console from here, never from rich directly.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from rich.theme import Theme
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
THEME = Theme({
|
|
10
|
+
"hive.primary": "#F5A623", # amber — brand, headers, highlights
|
|
11
|
+
"hive.secondary": "#4A9EFF", # electric blue — info, links, tool names
|
|
12
|
+
"hive.success": "#3DDC84", # green — completed, healthy
|
|
13
|
+
"hive.warning": "#FFD166", # yellow — warnings, SLA at risk
|
|
14
|
+
"hive.error": "#FF4757", # red — failures, errors
|
|
15
|
+
"hive.muted": "#6B7280", # gray — timestamps, secondary info
|
|
16
|
+
"hive.dim": "#374151", # dark gray — borders, dividers
|
|
17
|
+
"hive.agent": "#A78BFA", # purple — agent activity
|
|
18
|
+
"hive.tool": "#34D399", # teal — tool calls
|
|
19
|
+
"hive.planner": "#FB923C", # orange — planner activity
|
|
20
|
+
"hive.cost": "#F472B6", # pink — cost/token info
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
# Respect NO_COLOR and --no-color (set by main before first use)
|
|
24
|
+
def _make_console(**kwargs: object) -> Console:
|
|
25
|
+
return Console(theme=THEME, highlight=False, **kwargs)
|
|
26
|
+
|
|
27
|
+
console = _make_console()
|
|
28
|
+
err_console = _make_console(stderr=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reconfigure_console(no_color: bool = False, force_terminal: bool | None = None) -> None:
|
|
32
|
+
"""Reconfigure global consoles (e.g. for --no-color, --plain)."""
|
|
33
|
+
global console, err_console
|
|
34
|
+
kw: dict = {"theme": THEME, "highlight": False}
|
|
35
|
+
if no_color:
|
|
36
|
+
kw["no_color"] = True
|
|
37
|
+
if force_terminal is not None:
|
|
38
|
+
kw["force_terminal"] = force_terminal
|
|
39
|
+
console = Console(**kw)
|
|
40
|
+
err_console = Console(stderr=True, **kw)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Cluster membership, registry, election, state backend, and task routing."""
|
|
2
|
+
|
|
3
|
+
from devsper.cluster.node_info import (
|
|
4
|
+
ClusterState,
|
|
5
|
+
NodeInfo,
|
|
6
|
+
NodeRole,
|
|
7
|
+
)
|
|
8
|
+
from devsper.cluster.registry import ClusterRegistry
|
|
9
|
+
from devsper.cluster.election import LeaderElector
|
|
10
|
+
from devsper.cluster.state_backend import (
|
|
11
|
+
StateBackend,
|
|
12
|
+
RedisStateBackend,
|
|
13
|
+
FilesystemStateBackend,
|
|
14
|
+
get_state_backend,
|
|
15
|
+
)
|
|
16
|
+
from devsper.cluster.router import TaskRouter
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ClusterRegistry",
|
|
20
|
+
"ClusterState",
|
|
21
|
+
"NodeInfo",
|
|
22
|
+
"NodeRole",
|
|
23
|
+
"LeaderElector",
|
|
24
|
+
"StateBackend",
|
|
25
|
+
"RedisStateBackend",
|
|
26
|
+
"FilesystemStateBackend",
|
|
27
|
+
"get_state_backend",
|
|
28
|
+
"TaskRouter",
|
|
29
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Leader election via Redis SET NX + TTL."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
LEADER_KEY_PREFIX = "devsper:leader:"
|
|
7
|
+
LEADER_TTL = 15
|
|
8
|
+
REFRESH_INTERVAL = 5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LeaderElector:
|
|
12
|
+
"""Distributed leader election. Key: devsper:leader:{run_id}, value: node_id, TTL 15s."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, redis_client: object, run_id: str) -> None:
|
|
15
|
+
self._redis = redis_client
|
|
16
|
+
self._run_id = run_id
|
|
17
|
+
self._key = f"{LEADER_KEY_PREFIX}{run_id}"
|
|
18
|
+
|
|
19
|
+
async def campaign(self, node_id: str) -> bool:
|
|
20
|
+
"""SET key node_id NX EX 15. Returns True if this node won."""
|
|
21
|
+
try:
|
|
22
|
+
return await self._redis.set(
|
|
23
|
+
self._key, node_id, nx=True, ex=LEADER_TTL
|
|
24
|
+
)
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
async def refresh(self, node_id: str) -> bool:
|
|
29
|
+
"""Atomic: if current value == node_id, EXPIRE 15 and return True."""
|
|
30
|
+
script = """
|
|
31
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
32
|
+
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
|
33
|
+
return 1
|
|
34
|
+
else
|
|
35
|
+
return 0
|
|
36
|
+
end
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
result = await self._redis.eval(script, 1, self._key, node_id, LEADER_TTL)
|
|
40
|
+
return bool(result)
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
async def get_leader(self) -> str | None:
|
|
45
|
+
raw = await self._redis.get(self._key)
|
|
46
|
+
if raw is None:
|
|
47
|
+
return None
|
|
48
|
+
return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
|
|
49
|
+
|
|
50
|
+
async def abdicate(self, node_id: str) -> None:
|
|
51
|
+
script = """
|
|
52
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
53
|
+
redis.call('DEL', KEYS[1])
|
|
54
|
+
end
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
await self._redis.eval(script, 1, self._key, node_id)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
async def watch(
|
|
62
|
+
self,
|
|
63
|
+
node_id: str,
|
|
64
|
+
on_elected: Callable[[], object],
|
|
65
|
+
on_lost: Callable[[], object],
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Loop every 5s: refresh if leader, else campaign; call on_elected/on_lost on transition."""
|
|
68
|
+
currently_leader = False
|
|
69
|
+
while True:
|
|
70
|
+
try:
|
|
71
|
+
await asyncio.sleep(REFRESH_INTERVAL)
|
|
72
|
+
if currently_leader:
|
|
73
|
+
if await self.refresh(node_id):
|
|
74
|
+
continue
|
|
75
|
+
currently_leader = False
|
|
76
|
+
await on_lost()
|
|
77
|
+
else:
|
|
78
|
+
if await self.campaign(node_id):
|
|
79
|
+
currently_leader = True
|
|
80
|
+
await on_elected()
|
|
81
|
+
except asyncio.CancelledError:
|
|
82
|
+
break
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
devsper/cluster/local.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""In-memory registry and elector for single-node mode (no Redis)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from devsper.cluster.node_info import NodeInfo, NodeRole
|
|
5
|
+
from devsper.cluster.registry import ClusterRegistry
|
|
6
|
+
from devsper.cluster.election import LeaderElector
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InMemoryRegistry:
|
|
10
|
+
"""Registry backed by a dict. Single-node only."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, run_id: str) -> None:
|
|
13
|
+
self._run_id = run_id
|
|
14
|
+
self._nodes: dict[str, str] = {}
|
|
15
|
+
|
|
16
|
+
async def register(self, node: NodeInfo) -> None:
|
|
17
|
+
self._nodes[node.node_id] = node.to_json()
|
|
18
|
+
|
|
19
|
+
async def heartbeat(self, node_id: str, updates: dict) -> None:
|
|
20
|
+
if node_id not in self._nodes:
|
|
21
|
+
return
|
|
22
|
+
import json
|
|
23
|
+
data = json.loads(self._nodes[node_id])
|
|
24
|
+
data.update(updates)
|
|
25
|
+
self._nodes[node_id] = json.dumps(data)
|
|
26
|
+
|
|
27
|
+
async def deregister(self, node_id: str) -> None:
|
|
28
|
+
self._nodes.pop(node_id, None)
|
|
29
|
+
|
|
30
|
+
async def get_all(self) -> list[NodeInfo]:
|
|
31
|
+
out = []
|
|
32
|
+
for v in self._nodes.values():
|
|
33
|
+
try:
|
|
34
|
+
out.append(NodeInfo.from_json(v))
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
async def get_node(self, node_id: str) -> NodeInfo | None:
|
|
40
|
+
raw = self._nodes.get(node_id)
|
|
41
|
+
if not raw:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
return NodeInfo.from_json(raw)
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
async def get_controllers(self) -> list[NodeInfo]:
|
|
49
|
+
all_nodes = await self.get_all()
|
|
50
|
+
return [n for n in all_nodes if n.role in (NodeRole.CONTROLLER, NodeRole.HYBRID)]
|
|
51
|
+
|
|
52
|
+
async def get_workers(self) -> list[NodeInfo]:
|
|
53
|
+
all_nodes = await self.get_all()
|
|
54
|
+
return [n for n in all_nodes if n.role in (NodeRole.WORKER, NodeRole.HYBRID)]
|
|
55
|
+
|
|
56
|
+
async def is_healthy(self) -> bool:
|
|
57
|
+
return len(await self.get_controllers()) >= 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LocalLeaderElector:
|
|
61
|
+
"""Single-node: first campaigner wins; no Redis."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, run_id: str) -> None:
|
|
64
|
+
self._run_id = run_id
|
|
65
|
+
self._leader: str | None = None
|
|
66
|
+
|
|
67
|
+
async def campaign(self, node_id: str) -> bool:
|
|
68
|
+
if self._leader is None:
|
|
69
|
+
self._leader = node_id
|
|
70
|
+
return True
|
|
71
|
+
return self._leader == node_id
|
|
72
|
+
|
|
73
|
+
async def refresh(self, node_id: str) -> bool:
|
|
74
|
+
return self._leader == node_id
|
|
75
|
+
|
|
76
|
+
async def get_leader(self) -> str | None:
|
|
77
|
+
return self._leader
|
|
78
|
+
|
|
79
|
+
async def abdicate(self, node_id: str) -> None:
|
|
80
|
+
if self._leader == node_id:
|
|
81
|
+
self._leader = None
|
|
82
|
+
|
|
83
|
+
async def watch(
|
|
84
|
+
self,
|
|
85
|
+
node_id: str,
|
|
86
|
+
on_elected: object,
|
|
87
|
+
on_lost: object,
|
|
88
|
+
) -> None:
|
|
89
|
+
won = await self.campaign(node_id)
|
|
90
|
+
if won:
|
|
91
|
+
await on_elected()
|
|
92
|
+
while True:
|
|
93
|
+
# Short sleep so dispatch_loop and worker get CPU in single-node
|
|
94
|
+
await asyncio.sleep(0.5)
|
|
95
|
+
if not await self.refresh(node_id):
|
|
96
|
+
await on_lost()
|
|
97
|
+
break
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Node metadata and cluster state for distributed execution."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NodeRole(Enum):
|
|
10
|
+
CONTROLLER = "controller"
|
|
11
|
+
WORKER = "worker"
|
|
12
|
+
HYBRID = "hybrid"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class NodeInfo:
|
|
17
|
+
"""Serializable node registration info."""
|
|
18
|
+
node_id: str
|
|
19
|
+
role: NodeRole
|
|
20
|
+
host: str
|
|
21
|
+
rpc_port: int
|
|
22
|
+
rpc_url: str
|
|
23
|
+
tags: list[str]
|
|
24
|
+
max_workers: int
|
|
25
|
+
joined_at: str
|
|
26
|
+
last_heartbeat: str
|
|
27
|
+
version: str
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"node_id": self.node_id,
|
|
32
|
+
"role": self.role.value,
|
|
33
|
+
"host": self.host,
|
|
34
|
+
"rpc_port": self.rpc_port,
|
|
35
|
+
"rpc_url": self.rpc_url,
|
|
36
|
+
"tags": list(self.tags),
|
|
37
|
+
"max_workers": self.max_workers,
|
|
38
|
+
"joined_at": self.joined_at,
|
|
39
|
+
"last_heartbeat": self.last_heartbeat,
|
|
40
|
+
"version": self.version,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: dict[str, Any]) -> "NodeInfo":
|
|
45
|
+
role_val = data.get("role", "worker")
|
|
46
|
+
if isinstance(role_val, NodeRole):
|
|
47
|
+
role = role_val
|
|
48
|
+
else:
|
|
49
|
+
role = NodeRole(role_val) if role_val in ("controller", "worker", "hybrid") else NodeRole.WORKER
|
|
50
|
+
return cls(
|
|
51
|
+
node_id=data["node_id"],
|
|
52
|
+
role=role,
|
|
53
|
+
host=data.get("host", ""),
|
|
54
|
+
rpc_port=int(data.get("rpc_port", 0)),
|
|
55
|
+
rpc_url=data.get("rpc_url", ""),
|
|
56
|
+
tags=list(data.get("tags", [])),
|
|
57
|
+
max_workers=int(data.get("max_workers", 1)),
|
|
58
|
+
joined_at=data.get("joined_at", ""),
|
|
59
|
+
last_heartbeat=data.get("last_heartbeat", ""),
|
|
60
|
+
version=data.get("version", ""),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def to_json(self) -> str:
|
|
64
|
+
return json.dumps(self.to_dict())
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_json(cls, raw: str) -> "NodeInfo":
|
|
68
|
+
return cls.from_dict(json.loads(raw))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ClusterState:
|
|
73
|
+
"""Current view of the cluster."""
|
|
74
|
+
nodes: dict[str, NodeInfo] = field(default_factory=dict)
|
|
75
|
+
controller_id: str | None = None
|
|
76
|
+
run_id: str | None = None
|
|
77
|
+
quorum: bool = False
|