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,1114 @@
|
|
|
1
|
+
"""Collaboration Features for Socratic Workflow Builder
|
|
2
|
+
|
|
3
|
+
Enables multiple users to collaboratively refine workflow requirements
|
|
4
|
+
and review generated workflows.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Collaborative sessions with multiple participants
|
|
8
|
+
- Comment and discussion threads
|
|
9
|
+
- Voting on requirements and decisions
|
|
10
|
+
- Change tracking and history
|
|
11
|
+
- Conflict resolution
|
|
12
|
+
- Real-time synchronization support
|
|
13
|
+
|
|
14
|
+
Copyright 2026 Smart-AI-Memory
|
|
15
|
+
Licensed under Fair Source License 0.9
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# DATA STRUCTURES
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ParticipantRole(Enum):
|
|
36
|
+
"""Roles for session participants."""
|
|
37
|
+
|
|
38
|
+
OWNER = "owner" # Full control
|
|
39
|
+
EDITOR = "editor" # Can edit and vote
|
|
40
|
+
REVIEWER = "reviewer" # Can comment and vote
|
|
41
|
+
VIEWER = "viewer" # Read-only access
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CommentStatus(Enum):
|
|
45
|
+
"""Status of a comment."""
|
|
46
|
+
|
|
47
|
+
OPEN = "open"
|
|
48
|
+
RESOLVED = "resolved"
|
|
49
|
+
WONT_FIX = "wont_fix"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class VoteType(Enum):
|
|
53
|
+
"""Types of votes."""
|
|
54
|
+
|
|
55
|
+
APPROVE = "approve"
|
|
56
|
+
REJECT = "reject"
|
|
57
|
+
ABSTAIN = "abstain"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ChangeType(Enum):
|
|
61
|
+
"""Types of changes tracked."""
|
|
62
|
+
|
|
63
|
+
GOAL_SET = "goal_set"
|
|
64
|
+
ANSWER_SUBMITTED = "answer_submitted"
|
|
65
|
+
REQUIREMENT_ADDED = "requirement_added"
|
|
66
|
+
REQUIREMENT_REMOVED = "requirement_removed"
|
|
67
|
+
AGENT_ADDED = "agent_added"
|
|
68
|
+
AGENT_REMOVED = "agent_removed"
|
|
69
|
+
WORKFLOW_MODIFIED = "workflow_modified"
|
|
70
|
+
COMMENT_ADDED = "comment_added"
|
|
71
|
+
VOTE_CAST = "vote_cast"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Participant:
|
|
76
|
+
"""A participant in a collaborative session."""
|
|
77
|
+
|
|
78
|
+
user_id: str
|
|
79
|
+
name: str
|
|
80
|
+
email: str | None = None
|
|
81
|
+
role: ParticipantRole = ParticipantRole.VIEWER
|
|
82
|
+
joined_at: datetime = field(default_factory=datetime.now)
|
|
83
|
+
last_active: datetime = field(default_factory=datetime.now)
|
|
84
|
+
is_online: bool = False
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict[str, Any]:
|
|
87
|
+
return {
|
|
88
|
+
"user_id": self.user_id,
|
|
89
|
+
"name": self.name,
|
|
90
|
+
"email": self.email,
|
|
91
|
+
"role": self.role.value,
|
|
92
|
+
"joined_at": self.joined_at.isoformat(),
|
|
93
|
+
"last_active": self.last_active.isoformat(),
|
|
94
|
+
"is_online": self.is_online,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, data: dict[str, Any]) -> Participant:
|
|
99
|
+
return cls(
|
|
100
|
+
user_id=data["user_id"],
|
|
101
|
+
name=data["name"],
|
|
102
|
+
email=data.get("email"),
|
|
103
|
+
role=ParticipantRole(data.get("role", "viewer")),
|
|
104
|
+
joined_at=datetime.fromisoformat(data["joined_at"]),
|
|
105
|
+
last_active=datetime.fromisoformat(data["last_active"]),
|
|
106
|
+
is_online=data.get("is_online", False),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Comment:
|
|
112
|
+
"""A comment on a session or specific element."""
|
|
113
|
+
|
|
114
|
+
comment_id: str
|
|
115
|
+
author_id: str
|
|
116
|
+
content: str
|
|
117
|
+
target_type: str # "session", "answer", "agent", "stage", etc.
|
|
118
|
+
target_id: str # ID of the target element
|
|
119
|
+
status: CommentStatus = CommentStatus.OPEN
|
|
120
|
+
parent_id: str | None = None # For threaded comments
|
|
121
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
122
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
123
|
+
reactions: dict[str, list[str]] = field(default_factory=dict) # emoji -> user_ids
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"comment_id": self.comment_id,
|
|
128
|
+
"author_id": self.author_id,
|
|
129
|
+
"content": self.content,
|
|
130
|
+
"target_type": self.target_type,
|
|
131
|
+
"target_id": self.target_id,
|
|
132
|
+
"status": self.status.value,
|
|
133
|
+
"parent_id": self.parent_id,
|
|
134
|
+
"created_at": self.created_at.isoformat(),
|
|
135
|
+
"updated_at": self.updated_at.isoformat(),
|
|
136
|
+
"reactions": self.reactions,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: dict[str, Any]) -> Comment:
|
|
141
|
+
return cls(
|
|
142
|
+
comment_id=data["comment_id"],
|
|
143
|
+
author_id=data["author_id"],
|
|
144
|
+
content=data["content"],
|
|
145
|
+
target_type=data["target_type"],
|
|
146
|
+
target_id=data["target_id"],
|
|
147
|
+
status=CommentStatus(data.get("status", "open")),
|
|
148
|
+
parent_id=data.get("parent_id"),
|
|
149
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
150
|
+
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
151
|
+
reactions=data.get("reactions", {}),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class Vote:
|
|
157
|
+
"""A vote on a decision or requirement."""
|
|
158
|
+
|
|
159
|
+
vote_id: str
|
|
160
|
+
voter_id: str
|
|
161
|
+
target_type: str # "requirement", "agent", "workflow", etc.
|
|
162
|
+
target_id: str
|
|
163
|
+
vote_type: VoteType
|
|
164
|
+
comment: str = ""
|
|
165
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> dict[str, Any]:
|
|
168
|
+
return {
|
|
169
|
+
"vote_id": self.vote_id,
|
|
170
|
+
"voter_id": self.voter_id,
|
|
171
|
+
"target_type": self.target_type,
|
|
172
|
+
"target_id": self.target_id,
|
|
173
|
+
"vote_type": self.vote_type.value,
|
|
174
|
+
"comment": self.comment,
|
|
175
|
+
"created_at": self.created_at.isoformat(),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_dict(cls, data: dict[str, Any]) -> Vote:
|
|
180
|
+
return cls(
|
|
181
|
+
vote_id=data["vote_id"],
|
|
182
|
+
voter_id=data["voter_id"],
|
|
183
|
+
target_type=data["target_type"],
|
|
184
|
+
target_id=data["target_id"],
|
|
185
|
+
vote_type=VoteType(data["vote_type"]),
|
|
186
|
+
comment=data.get("comment", ""),
|
|
187
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class Change:
|
|
193
|
+
"""A tracked change in the session."""
|
|
194
|
+
|
|
195
|
+
change_id: str
|
|
196
|
+
change_type: ChangeType
|
|
197
|
+
author_id: str
|
|
198
|
+
description: str
|
|
199
|
+
before_value: Any = None
|
|
200
|
+
after_value: Any = None
|
|
201
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
202
|
+
|
|
203
|
+
def to_dict(self) -> dict[str, Any]:
|
|
204
|
+
return {
|
|
205
|
+
"change_id": self.change_id,
|
|
206
|
+
"change_type": self.change_type.value,
|
|
207
|
+
"author_id": self.author_id,
|
|
208
|
+
"description": self.description,
|
|
209
|
+
"before_value": self.before_value,
|
|
210
|
+
"after_value": self.after_value,
|
|
211
|
+
"created_at": self.created_at.isoformat(),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def from_dict(cls, data: dict[str, Any]) -> Change:
|
|
216
|
+
return cls(
|
|
217
|
+
change_id=data["change_id"],
|
|
218
|
+
change_type=ChangeType(data["change_type"]),
|
|
219
|
+
author_id=data["author_id"],
|
|
220
|
+
description=data["description"],
|
|
221
|
+
before_value=data.get("before_value"),
|
|
222
|
+
after_value=data.get("after_value"),
|
|
223
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass
|
|
228
|
+
class VotingResult:
|
|
229
|
+
"""Result of a voting round."""
|
|
230
|
+
|
|
231
|
+
target_id: str
|
|
232
|
+
total_votes: int
|
|
233
|
+
approvals: int
|
|
234
|
+
rejections: int
|
|
235
|
+
abstentions: int
|
|
236
|
+
is_approved: bool
|
|
237
|
+
quorum_reached: bool
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def approval_rate(self) -> float:
|
|
241
|
+
"""Calculate approval rate (excluding abstentions)."""
|
|
242
|
+
active_votes = self.approvals + self.rejections
|
|
243
|
+
if active_votes == 0:
|
|
244
|
+
return 0.0
|
|
245
|
+
return self.approvals / active_votes
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# =============================================================================
|
|
249
|
+
# COLLABORATIVE SESSION
|
|
250
|
+
# =============================================================================
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class CollaborativeSession:
|
|
255
|
+
"""A session with collaboration features enabled."""
|
|
256
|
+
|
|
257
|
+
session_id: str
|
|
258
|
+
base_session_id: str # ID of the underlying SocraticSession
|
|
259
|
+
name: str
|
|
260
|
+
description: str = ""
|
|
261
|
+
participants: list[Participant] = field(default_factory=list)
|
|
262
|
+
comments: list[Comment] = field(default_factory=list)
|
|
263
|
+
votes: list[Vote] = field(default_factory=list)
|
|
264
|
+
changes: list[Change] = field(default_factory=list)
|
|
265
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
266
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
267
|
+
require_approval: bool = True
|
|
268
|
+
approval_threshold: float = 0.5 # 50% approval required
|
|
269
|
+
quorum: float = 0.5 # 50% participation required
|
|
270
|
+
|
|
271
|
+
def to_dict(self) -> dict[str, Any]:
|
|
272
|
+
return {
|
|
273
|
+
"session_id": self.session_id,
|
|
274
|
+
"base_session_id": self.base_session_id,
|
|
275
|
+
"name": self.name,
|
|
276
|
+
"description": self.description,
|
|
277
|
+
"participants": [p.to_dict() for p in self.participants],
|
|
278
|
+
"comments": [c.to_dict() for c in self.comments],
|
|
279
|
+
"votes": [v.to_dict() for v in self.votes],
|
|
280
|
+
"changes": [c.to_dict() for c in self.changes],
|
|
281
|
+
"created_at": self.created_at.isoformat(),
|
|
282
|
+
"updated_at": self.updated_at.isoformat(),
|
|
283
|
+
"require_approval": self.require_approval,
|
|
284
|
+
"approval_threshold": self.approval_threshold,
|
|
285
|
+
"quorum": self.quorum,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def from_dict(cls, data: dict[str, Any]) -> CollaborativeSession:
|
|
290
|
+
return cls(
|
|
291
|
+
session_id=data["session_id"],
|
|
292
|
+
base_session_id=data["base_session_id"],
|
|
293
|
+
name=data["name"],
|
|
294
|
+
description=data.get("description", ""),
|
|
295
|
+
participants=[Participant.from_dict(p) for p in data.get("participants", [])],
|
|
296
|
+
comments=[Comment.from_dict(c) for c in data.get("comments", [])],
|
|
297
|
+
votes=[Vote.from_dict(v) for v in data.get("votes", [])],
|
|
298
|
+
changes=[Change.from_dict(c) for c in data.get("changes", [])],
|
|
299
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
300
|
+
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
301
|
+
require_approval=data.get("require_approval", True),
|
|
302
|
+
approval_threshold=data.get("approval_threshold", 0.5),
|
|
303
|
+
quorum=data.get("quorum", 0.5),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# =============================================================================
|
|
308
|
+
# COLLABORATION MANAGER
|
|
309
|
+
# =============================================================================
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class CollaborationManager:
|
|
313
|
+
"""Manages collaborative workflow sessions."""
|
|
314
|
+
|
|
315
|
+
def __init__(self, storage_path: Path | str | None = None):
|
|
316
|
+
"""Initialize the manager.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
storage_path: Path to persist collaboration data
|
|
320
|
+
"""
|
|
321
|
+
if storage_path is None:
|
|
322
|
+
storage_path = Path.home() / ".empathy" / "socratic" / "collaboration"
|
|
323
|
+
self.storage_path = Path(storage_path)
|
|
324
|
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
|
|
326
|
+
self._sessions: dict[str, CollaborativeSession] = {}
|
|
327
|
+
self._change_listeners: list[Callable[[Change], None]] = []
|
|
328
|
+
|
|
329
|
+
self._load_sessions()
|
|
330
|
+
|
|
331
|
+
def create_session(
|
|
332
|
+
self,
|
|
333
|
+
base_session_id: str,
|
|
334
|
+
name: str,
|
|
335
|
+
owner_id: str,
|
|
336
|
+
owner_name: str,
|
|
337
|
+
description: str = "",
|
|
338
|
+
) -> CollaborativeSession:
|
|
339
|
+
"""Create a new collaborative session.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
base_session_id: ID of the underlying SocraticSession
|
|
343
|
+
name: Session name
|
|
344
|
+
owner_id: ID of the session owner
|
|
345
|
+
owner_name: Name of the session owner
|
|
346
|
+
description: Optional description
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
The created session
|
|
350
|
+
"""
|
|
351
|
+
session_id = hashlib.sha256(f"{base_session_id}:{time.time()}".encode()).hexdigest()[:12]
|
|
352
|
+
|
|
353
|
+
owner = Participant(
|
|
354
|
+
user_id=owner_id,
|
|
355
|
+
name=owner_name,
|
|
356
|
+
role=ParticipantRole.OWNER,
|
|
357
|
+
is_online=True,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
session = CollaborativeSession(
|
|
361
|
+
session_id=session_id,
|
|
362
|
+
base_session_id=base_session_id,
|
|
363
|
+
name=name,
|
|
364
|
+
description=description,
|
|
365
|
+
participants=[owner],
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
self._sessions[session_id] = session
|
|
369
|
+
self._save_session(session)
|
|
370
|
+
|
|
371
|
+
return session
|
|
372
|
+
|
|
373
|
+
def get_session(self, session_id: str) -> CollaborativeSession | None:
|
|
374
|
+
"""Get a collaborative session by ID."""
|
|
375
|
+
return self._sessions.get(session_id)
|
|
376
|
+
|
|
377
|
+
def add_participant(
|
|
378
|
+
self,
|
|
379
|
+
session_id: str,
|
|
380
|
+
user_id: str,
|
|
381
|
+
name: str,
|
|
382
|
+
email: str | None = None,
|
|
383
|
+
role: ParticipantRole = ParticipantRole.REVIEWER,
|
|
384
|
+
) -> Participant | None:
|
|
385
|
+
"""Add a participant to a session.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
session_id: Session ID
|
|
389
|
+
user_id: User ID
|
|
390
|
+
name: User name
|
|
391
|
+
email: Optional email
|
|
392
|
+
role: Participant role
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
The added participant or None
|
|
396
|
+
"""
|
|
397
|
+
session = self._sessions.get(session_id)
|
|
398
|
+
if not session:
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
# Check if already a participant
|
|
402
|
+
existing = next((p for p in session.participants if p.user_id == user_id), None)
|
|
403
|
+
if existing:
|
|
404
|
+
return existing
|
|
405
|
+
|
|
406
|
+
participant = Participant(
|
|
407
|
+
user_id=user_id,
|
|
408
|
+
name=name,
|
|
409
|
+
email=email,
|
|
410
|
+
role=role,
|
|
411
|
+
)
|
|
412
|
+
session.participants.append(participant)
|
|
413
|
+
session.updated_at = datetime.now()
|
|
414
|
+
|
|
415
|
+
self._save_session(session)
|
|
416
|
+
return participant
|
|
417
|
+
|
|
418
|
+
def update_participant_role(
|
|
419
|
+
self,
|
|
420
|
+
session_id: str,
|
|
421
|
+
user_id: str,
|
|
422
|
+
new_role: ParticipantRole,
|
|
423
|
+
by_user_id: str,
|
|
424
|
+
) -> bool:
|
|
425
|
+
"""Update a participant's role.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
session_id: Session ID
|
|
429
|
+
user_id: User ID to update
|
|
430
|
+
new_role: New role
|
|
431
|
+
by_user_id: ID of user making the change
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
True if successful
|
|
435
|
+
"""
|
|
436
|
+
session = self._sessions.get(session_id)
|
|
437
|
+
if not session:
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
# Check permissions
|
|
441
|
+
requester = next((p for p in session.participants if p.user_id == by_user_id), None)
|
|
442
|
+
if not requester or requester.role != ParticipantRole.OWNER:
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
participant = next((p for p in session.participants if p.user_id == user_id), None)
|
|
446
|
+
if not participant:
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
participant.role = new_role
|
|
450
|
+
session.updated_at = datetime.now()
|
|
451
|
+
|
|
452
|
+
self._save_session(session)
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
def add_comment(
|
|
456
|
+
self,
|
|
457
|
+
session_id: str,
|
|
458
|
+
author_id: str,
|
|
459
|
+
content: str,
|
|
460
|
+
target_type: str,
|
|
461
|
+
target_id: str,
|
|
462
|
+
parent_id: str | None = None,
|
|
463
|
+
) -> Comment | None:
|
|
464
|
+
"""Add a comment to a session.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
session_id: Session ID
|
|
468
|
+
author_id: Comment author ID
|
|
469
|
+
content: Comment content
|
|
470
|
+
target_type: Type of target element
|
|
471
|
+
target_id: ID of target element
|
|
472
|
+
parent_id: Optional parent comment ID for threading
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
The created comment or None
|
|
476
|
+
"""
|
|
477
|
+
session = self._sessions.get(session_id)
|
|
478
|
+
if not session:
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
# Verify author is a participant
|
|
482
|
+
author = next((p for p in session.participants if p.user_id == author_id), None)
|
|
483
|
+
if not author:
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
comment_id = hashlib.sha256(f"{session_id}:{author_id}:{time.time()}".encode()).hexdigest()[
|
|
487
|
+
:12
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
comment = Comment(
|
|
491
|
+
comment_id=comment_id,
|
|
492
|
+
author_id=author_id,
|
|
493
|
+
content=content,
|
|
494
|
+
target_type=target_type,
|
|
495
|
+
target_id=target_id,
|
|
496
|
+
parent_id=parent_id,
|
|
497
|
+
)
|
|
498
|
+
session.comments.append(comment)
|
|
499
|
+
session.updated_at = datetime.now()
|
|
500
|
+
|
|
501
|
+
# Track change
|
|
502
|
+
self._track_change(
|
|
503
|
+
session,
|
|
504
|
+
ChangeType.COMMENT_ADDED,
|
|
505
|
+
author_id,
|
|
506
|
+
f"Comment added on {target_type}",
|
|
507
|
+
after_value={"comment_id": comment_id, "target": target_id},
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
self._save_session(session)
|
|
511
|
+
return comment
|
|
512
|
+
|
|
513
|
+
def resolve_comment(
|
|
514
|
+
self,
|
|
515
|
+
session_id: str,
|
|
516
|
+
comment_id: str,
|
|
517
|
+
by_user_id: str,
|
|
518
|
+
status: CommentStatus = CommentStatus.RESOLVED,
|
|
519
|
+
) -> bool:
|
|
520
|
+
"""Resolve a comment.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
session_id: Session ID
|
|
524
|
+
comment_id: Comment ID
|
|
525
|
+
by_user_id: User resolving the comment
|
|
526
|
+
status: New status
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
True if successful
|
|
530
|
+
"""
|
|
531
|
+
session = self._sessions.get(session_id)
|
|
532
|
+
if not session:
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
comment = next((c for c in session.comments if c.comment_id == comment_id), None)
|
|
536
|
+
if not comment:
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
comment.status = status
|
|
540
|
+
comment.updated_at = datetime.now()
|
|
541
|
+
session.updated_at = datetime.now()
|
|
542
|
+
|
|
543
|
+
self._save_session(session)
|
|
544
|
+
return True
|
|
545
|
+
|
|
546
|
+
def add_reaction(
|
|
547
|
+
self,
|
|
548
|
+
session_id: str,
|
|
549
|
+
comment_id: str,
|
|
550
|
+
user_id: str,
|
|
551
|
+
emoji: str,
|
|
552
|
+
) -> bool:
|
|
553
|
+
"""Add a reaction to a comment.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
session_id: Session ID
|
|
557
|
+
comment_id: Comment ID
|
|
558
|
+
user_id: User adding reaction
|
|
559
|
+
emoji: Emoji reaction
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
True if successful
|
|
563
|
+
"""
|
|
564
|
+
session = self._sessions.get(session_id)
|
|
565
|
+
if not session:
|
|
566
|
+
return False
|
|
567
|
+
|
|
568
|
+
comment = next((c for c in session.comments if c.comment_id == comment_id), None)
|
|
569
|
+
if not comment:
|
|
570
|
+
return False
|
|
571
|
+
|
|
572
|
+
if emoji not in comment.reactions:
|
|
573
|
+
comment.reactions[emoji] = []
|
|
574
|
+
|
|
575
|
+
if user_id not in comment.reactions[emoji]:
|
|
576
|
+
comment.reactions[emoji].append(user_id)
|
|
577
|
+
session.updated_at = datetime.now()
|
|
578
|
+
self._save_session(session)
|
|
579
|
+
|
|
580
|
+
return True
|
|
581
|
+
|
|
582
|
+
def cast_vote(
|
|
583
|
+
self,
|
|
584
|
+
session_id: str,
|
|
585
|
+
voter_id: str,
|
|
586
|
+
target_type: str,
|
|
587
|
+
target_id: str,
|
|
588
|
+
vote_type: VoteType,
|
|
589
|
+
comment: str = "",
|
|
590
|
+
) -> Vote | None:
|
|
591
|
+
"""Cast a vote on a target.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
session_id: Session ID
|
|
595
|
+
voter_id: Voter ID
|
|
596
|
+
target_type: Type of target
|
|
597
|
+
target_id: ID of target
|
|
598
|
+
vote_type: Type of vote
|
|
599
|
+
comment: Optional comment
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
The cast vote or None
|
|
603
|
+
"""
|
|
604
|
+
session = self._sessions.get(session_id)
|
|
605
|
+
if not session:
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
# Verify voter is a participant with voting rights
|
|
609
|
+
voter = next((p for p in session.participants if p.user_id == voter_id), None)
|
|
610
|
+
if not voter or voter.role == ParticipantRole.VIEWER:
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
# Check if already voted
|
|
614
|
+
existing = next(
|
|
615
|
+
(
|
|
616
|
+
v
|
|
617
|
+
for v in session.votes
|
|
618
|
+
if v.voter_id == voter_id
|
|
619
|
+
and v.target_type == target_type
|
|
620
|
+
and v.target_id == target_id
|
|
621
|
+
),
|
|
622
|
+
None,
|
|
623
|
+
)
|
|
624
|
+
if existing:
|
|
625
|
+
# Update existing vote
|
|
626
|
+
existing.vote_type = vote_type
|
|
627
|
+
existing.comment = comment
|
|
628
|
+
existing.created_at = datetime.now()
|
|
629
|
+
session.updated_at = datetime.now()
|
|
630
|
+
self._save_session(session)
|
|
631
|
+
return existing
|
|
632
|
+
|
|
633
|
+
vote_id = hashlib.sha256(
|
|
634
|
+
f"{session_id}:{voter_id}:{target_id}:{time.time()}".encode()
|
|
635
|
+
).hexdigest()[:12]
|
|
636
|
+
|
|
637
|
+
vote = Vote(
|
|
638
|
+
vote_id=vote_id,
|
|
639
|
+
voter_id=voter_id,
|
|
640
|
+
target_type=target_type,
|
|
641
|
+
target_id=target_id,
|
|
642
|
+
vote_type=vote_type,
|
|
643
|
+
comment=comment,
|
|
644
|
+
)
|
|
645
|
+
session.votes.append(vote)
|
|
646
|
+
session.updated_at = datetime.now()
|
|
647
|
+
|
|
648
|
+
# Track change
|
|
649
|
+
self._track_change(
|
|
650
|
+
session,
|
|
651
|
+
ChangeType.VOTE_CAST,
|
|
652
|
+
voter_id,
|
|
653
|
+
f"{vote_type.value} vote on {target_type}",
|
|
654
|
+
after_value={"target": target_id, "vote": vote_type.value},
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
self._save_session(session)
|
|
658
|
+
return vote
|
|
659
|
+
|
|
660
|
+
def get_voting_result(
|
|
661
|
+
self,
|
|
662
|
+
session_id: str,
|
|
663
|
+
target_type: str,
|
|
664
|
+
target_id: str,
|
|
665
|
+
) -> VotingResult | None:
|
|
666
|
+
"""Get voting results for a target.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
session_id: Session ID
|
|
670
|
+
target_type: Type of target
|
|
671
|
+
target_id: ID of target
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
VotingResult or None
|
|
675
|
+
"""
|
|
676
|
+
session = self._sessions.get(session_id)
|
|
677
|
+
if not session:
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
# Get votes for this target
|
|
681
|
+
votes = [
|
|
682
|
+
v for v in session.votes if v.target_type == target_type and v.target_id == target_id
|
|
683
|
+
]
|
|
684
|
+
|
|
685
|
+
# Count by type
|
|
686
|
+
approvals = sum(1 for v in votes if v.vote_type == VoteType.APPROVE)
|
|
687
|
+
rejections = sum(1 for v in votes if v.vote_type == VoteType.REJECT)
|
|
688
|
+
abstentions = sum(1 for v in votes if v.vote_type == VoteType.ABSTAIN)
|
|
689
|
+
|
|
690
|
+
# Calculate quorum
|
|
691
|
+
eligible_voters = [p for p in session.participants if p.role != ParticipantRole.VIEWER]
|
|
692
|
+
participation_rate = len(votes) / len(eligible_voters) if eligible_voters else 0
|
|
693
|
+
quorum_reached = participation_rate >= session.quorum
|
|
694
|
+
|
|
695
|
+
# Calculate approval
|
|
696
|
+
active_votes = approvals + rejections
|
|
697
|
+
approval_rate = approvals / active_votes if active_votes > 0 else 0
|
|
698
|
+
is_approved = quorum_reached and approval_rate >= session.approval_threshold
|
|
699
|
+
|
|
700
|
+
return VotingResult(
|
|
701
|
+
target_id=target_id,
|
|
702
|
+
total_votes=len(votes),
|
|
703
|
+
approvals=approvals,
|
|
704
|
+
rejections=rejections,
|
|
705
|
+
abstentions=abstentions,
|
|
706
|
+
is_approved=is_approved,
|
|
707
|
+
quorum_reached=quorum_reached,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
def get_comments_for_target(
|
|
711
|
+
self,
|
|
712
|
+
session_id: str,
|
|
713
|
+
target_type: str,
|
|
714
|
+
target_id: str,
|
|
715
|
+
include_resolved: bool = True,
|
|
716
|
+
) -> list[Comment]:
|
|
717
|
+
"""Get comments for a target.
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
session_id: Session ID
|
|
721
|
+
target_type: Type of target
|
|
722
|
+
target_id: ID of target
|
|
723
|
+
include_resolved: Include resolved comments
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
List of comments
|
|
727
|
+
"""
|
|
728
|
+
session = self._sessions.get(session_id)
|
|
729
|
+
if not session:
|
|
730
|
+
return []
|
|
731
|
+
|
|
732
|
+
comments = [
|
|
733
|
+
c for c in session.comments if c.target_type == target_type and c.target_id == target_id
|
|
734
|
+
]
|
|
735
|
+
|
|
736
|
+
if not include_resolved:
|
|
737
|
+
comments = [c for c in comments if c.status == CommentStatus.OPEN]
|
|
738
|
+
|
|
739
|
+
return sorted(comments, key=lambda c: c.created_at)
|
|
740
|
+
|
|
741
|
+
def get_change_history(
|
|
742
|
+
self,
|
|
743
|
+
session_id: str,
|
|
744
|
+
limit: int = 50,
|
|
745
|
+
) -> list[Change]:
|
|
746
|
+
"""Get change history for a session.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
session_id: Session ID
|
|
750
|
+
limit: Maximum changes to return
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
List of changes (most recent first)
|
|
754
|
+
"""
|
|
755
|
+
session = self._sessions.get(session_id)
|
|
756
|
+
if not session:
|
|
757
|
+
return []
|
|
758
|
+
|
|
759
|
+
return sorted(
|
|
760
|
+
session.changes,
|
|
761
|
+
key=lambda c: c.created_at,
|
|
762
|
+
reverse=True,
|
|
763
|
+
)[:limit]
|
|
764
|
+
|
|
765
|
+
def track_change(
|
|
766
|
+
self,
|
|
767
|
+
session_id: str,
|
|
768
|
+
change_type: ChangeType,
|
|
769
|
+
author_id: str,
|
|
770
|
+
description: str,
|
|
771
|
+
before_value: Any = None,
|
|
772
|
+
after_value: Any = None,
|
|
773
|
+
):
|
|
774
|
+
"""Track a change in the session.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
session_id: Session ID
|
|
778
|
+
change_type: Type of change
|
|
779
|
+
author_id: Author of the change
|
|
780
|
+
description: Description of the change
|
|
781
|
+
before_value: Value before change
|
|
782
|
+
after_value: Value after change
|
|
783
|
+
"""
|
|
784
|
+
session = self._sessions.get(session_id)
|
|
785
|
+
if session:
|
|
786
|
+
self._track_change(
|
|
787
|
+
session, change_type, author_id, description, before_value, after_value
|
|
788
|
+
)
|
|
789
|
+
self._save_session(session)
|
|
790
|
+
|
|
791
|
+
def add_change_listener(self, listener: Callable[[Change], None]):
|
|
792
|
+
"""Add a listener for changes.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
listener: Callback function
|
|
796
|
+
"""
|
|
797
|
+
self._change_listeners.append(listener)
|
|
798
|
+
|
|
799
|
+
def remove_change_listener(self, listener: Callable[[Change], None]):
|
|
800
|
+
"""Remove a change listener.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
listener: Callback function to remove
|
|
804
|
+
"""
|
|
805
|
+
if listener in self._change_listeners:
|
|
806
|
+
self._change_listeners.remove(listener)
|
|
807
|
+
|
|
808
|
+
def _track_change(
|
|
809
|
+
self,
|
|
810
|
+
session: CollaborativeSession,
|
|
811
|
+
change_type: ChangeType,
|
|
812
|
+
author_id: str,
|
|
813
|
+
description: str,
|
|
814
|
+
before_value: Any = None,
|
|
815
|
+
after_value: Any = None,
|
|
816
|
+
):
|
|
817
|
+
"""Internal method to track a change."""
|
|
818
|
+
change_id = hashlib.sha256(f"{session.session_id}:{time.time()}".encode()).hexdigest()[:12]
|
|
819
|
+
|
|
820
|
+
change = Change(
|
|
821
|
+
change_id=change_id,
|
|
822
|
+
change_type=change_type,
|
|
823
|
+
author_id=author_id,
|
|
824
|
+
description=description,
|
|
825
|
+
before_value=before_value,
|
|
826
|
+
after_value=after_value,
|
|
827
|
+
)
|
|
828
|
+
session.changes.append(change)
|
|
829
|
+
|
|
830
|
+
# Notify listeners
|
|
831
|
+
for listener in self._change_listeners:
|
|
832
|
+
try:
|
|
833
|
+
listener(change)
|
|
834
|
+
except Exception: # noqa: BLE001
|
|
835
|
+
# INTENTIONAL: Listener failure should not break change tracking.
|
|
836
|
+
# One bad listener shouldn't prevent others from executing.
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
def _save_session(self, session: CollaborativeSession):
|
|
840
|
+
"""Save a session to storage."""
|
|
841
|
+
path = self.storage_path / f"{session.session_id}.json"
|
|
842
|
+
with path.open("w") as f:
|
|
843
|
+
json.dump(session.to_dict(), f, indent=2)
|
|
844
|
+
|
|
845
|
+
def _load_sessions(self):
|
|
846
|
+
"""Load all sessions from storage."""
|
|
847
|
+
for path in self.storage_path.glob("*.json"):
|
|
848
|
+
try:
|
|
849
|
+
with path.open("r") as f:
|
|
850
|
+
data = json.load(f)
|
|
851
|
+
session = CollaborativeSession.from_dict(data)
|
|
852
|
+
self._sessions[session.session_id] = session
|
|
853
|
+
except Exception: # noqa: BLE001
|
|
854
|
+
# INTENTIONAL: Skip corrupted session files gracefully.
|
|
855
|
+
# Loading should not fail due to one malformed file.
|
|
856
|
+
pass
|
|
857
|
+
|
|
858
|
+
def list_sessions(self) -> list[CollaborativeSession]:
|
|
859
|
+
"""List all collaborative sessions."""
|
|
860
|
+
return sorted(
|
|
861
|
+
self._sessions.values(),
|
|
862
|
+
key=lambda s: s.updated_at,
|
|
863
|
+
reverse=True,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
def get_user_sessions(self, user_id: str) -> list[CollaborativeSession]:
|
|
867
|
+
"""Get sessions for a specific user.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
user_id: User ID
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
List of sessions the user participates in
|
|
874
|
+
"""
|
|
875
|
+
return [
|
|
876
|
+
s for s in self._sessions.values() if any(p.user_id == user_id for p in s.participants)
|
|
877
|
+
]
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# =============================================================================
|
|
881
|
+
# REAL-TIME SYNC SUPPORT
|
|
882
|
+
# =============================================================================
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
@dataclass
|
|
886
|
+
class SyncEvent:
|
|
887
|
+
"""An event for real-time synchronization."""
|
|
888
|
+
|
|
889
|
+
event_id: str
|
|
890
|
+
session_id: str
|
|
891
|
+
event_type: str
|
|
892
|
+
payload: dict[str, Any]
|
|
893
|
+
author_id: str
|
|
894
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
895
|
+
|
|
896
|
+
def to_dict(self) -> dict[str, Any]:
|
|
897
|
+
return {
|
|
898
|
+
"event_id": self.event_id,
|
|
899
|
+
"session_id": self.session_id,
|
|
900
|
+
"event_type": self.event_type,
|
|
901
|
+
"payload": self.payload,
|
|
902
|
+
"author_id": self.author_id,
|
|
903
|
+
"timestamp": self.timestamp.isoformat(),
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
class SyncAdapter:
|
|
908
|
+
"""Adapter for real-time synchronization.
|
|
909
|
+
|
|
910
|
+
Override this class to integrate with your preferred
|
|
911
|
+
real-time infrastructure (WebSocket, SSE, etc.).
|
|
912
|
+
"""
|
|
913
|
+
|
|
914
|
+
def __init__(self, session_id: str):
|
|
915
|
+
"""Initialize the adapter.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
session_id: Session to sync
|
|
919
|
+
"""
|
|
920
|
+
self.session_id = session_id
|
|
921
|
+
self._event_handlers: list[Callable[[SyncEvent], None]] = []
|
|
922
|
+
|
|
923
|
+
def emit(self, event: SyncEvent):
|
|
924
|
+
"""Emit an event to all connected clients.
|
|
925
|
+
|
|
926
|
+
Override this to implement actual network transmission.
|
|
927
|
+
"""
|
|
928
|
+
for handler in self._event_handlers:
|
|
929
|
+
try:
|
|
930
|
+
handler(event)
|
|
931
|
+
except Exception: # noqa: BLE001
|
|
932
|
+
# INTENTIONAL: Handler failure should not prevent other handlers.
|
|
933
|
+
# Event propagation must continue for sync reliability.
|
|
934
|
+
pass
|
|
935
|
+
|
|
936
|
+
def on_event(self, handler: Callable[[SyncEvent], None]):
|
|
937
|
+
"""Register an event handler.
|
|
938
|
+
|
|
939
|
+
Args:
|
|
940
|
+
handler: Callback for incoming events
|
|
941
|
+
"""
|
|
942
|
+
self._event_handlers.append(handler)
|
|
943
|
+
|
|
944
|
+
def create_event(
|
|
945
|
+
self,
|
|
946
|
+
event_type: str,
|
|
947
|
+
payload: dict[str, Any],
|
|
948
|
+
author_id: str,
|
|
949
|
+
) -> SyncEvent:
|
|
950
|
+
"""Create a sync event.
|
|
951
|
+
|
|
952
|
+
Args:
|
|
953
|
+
event_type: Type of event
|
|
954
|
+
payload: Event data
|
|
955
|
+
author_id: Author of the event
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
Created SyncEvent
|
|
959
|
+
"""
|
|
960
|
+
event_id = hashlib.sha256(
|
|
961
|
+
f"{self.session_id}:{event_type}:{time.time()}".encode()
|
|
962
|
+
).hexdigest()[:12]
|
|
963
|
+
|
|
964
|
+
return SyncEvent(
|
|
965
|
+
event_id=event_id,
|
|
966
|
+
session_id=self.session_id,
|
|
967
|
+
event_type=event_type,
|
|
968
|
+
payload=payload,
|
|
969
|
+
author_id=author_id,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# =============================================================================
|
|
974
|
+
# INVITATION MANAGEMENT
|
|
975
|
+
# =============================================================================
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
@dataclass
|
|
979
|
+
class Invitation:
|
|
980
|
+
"""An invitation to join a collaborative session."""
|
|
981
|
+
|
|
982
|
+
invite_id: str
|
|
983
|
+
session_id: str
|
|
984
|
+
inviter_id: str
|
|
985
|
+
invitee_email: str
|
|
986
|
+
role: ParticipantRole
|
|
987
|
+
message: str = ""
|
|
988
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
989
|
+
expires_at: datetime | None = None
|
|
990
|
+
accepted: bool = False
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
class InvitationManager:
|
|
994
|
+
"""Manages invitations to collaborative sessions."""
|
|
995
|
+
|
|
996
|
+
def __init__(self, collaboration_manager: CollaborationManager):
|
|
997
|
+
"""Initialize the manager.
|
|
998
|
+
|
|
999
|
+
Args:
|
|
1000
|
+
collaboration_manager: The collaboration manager
|
|
1001
|
+
"""
|
|
1002
|
+
self.collab = collaboration_manager
|
|
1003
|
+
self._invitations: dict[str, Invitation] = {}
|
|
1004
|
+
|
|
1005
|
+
def create_invitation(
|
|
1006
|
+
self,
|
|
1007
|
+
session_id: str,
|
|
1008
|
+
inviter_id: str,
|
|
1009
|
+
invitee_email: str,
|
|
1010
|
+
role: ParticipantRole = ParticipantRole.REVIEWER,
|
|
1011
|
+
message: str = "",
|
|
1012
|
+
expires_hours: int = 72,
|
|
1013
|
+
) -> Invitation | None:
|
|
1014
|
+
"""Create an invitation.
|
|
1015
|
+
|
|
1016
|
+
Args:
|
|
1017
|
+
session_id: Session ID
|
|
1018
|
+
inviter_id: ID of user sending invite
|
|
1019
|
+
invitee_email: Email of invitee
|
|
1020
|
+
role: Role to assign
|
|
1021
|
+
message: Optional message
|
|
1022
|
+
expires_hours: Hours until expiration
|
|
1023
|
+
|
|
1024
|
+
Returns:
|
|
1025
|
+
Created invitation or None
|
|
1026
|
+
"""
|
|
1027
|
+
session = self.collab.get_session(session_id)
|
|
1028
|
+
if not session:
|
|
1029
|
+
return None
|
|
1030
|
+
|
|
1031
|
+
# Verify inviter has permission
|
|
1032
|
+
inviter = next((p for p in session.participants if p.user_id == inviter_id), None)
|
|
1033
|
+
if not inviter or inviter.role not in [ParticipantRole.OWNER, ParticipantRole.EDITOR]:
|
|
1034
|
+
return None
|
|
1035
|
+
|
|
1036
|
+
invite_id = hashlib.sha256(
|
|
1037
|
+
f"{session_id}:{invitee_email}:{time.time()}".encode()
|
|
1038
|
+
).hexdigest()[:16]
|
|
1039
|
+
|
|
1040
|
+
from datetime import timedelta
|
|
1041
|
+
|
|
1042
|
+
expires = datetime.now() + timedelta(hours=expires_hours)
|
|
1043
|
+
|
|
1044
|
+
invitation = Invitation(
|
|
1045
|
+
invite_id=invite_id,
|
|
1046
|
+
session_id=session_id,
|
|
1047
|
+
inviter_id=inviter_id,
|
|
1048
|
+
invitee_email=invitee_email,
|
|
1049
|
+
role=role,
|
|
1050
|
+
message=message,
|
|
1051
|
+
expires_at=expires,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
self._invitations[invite_id] = invitation
|
|
1055
|
+
return invitation
|
|
1056
|
+
|
|
1057
|
+
def accept_invitation(
|
|
1058
|
+
self,
|
|
1059
|
+
invite_id: str,
|
|
1060
|
+
user_id: str,
|
|
1061
|
+
user_name: str,
|
|
1062
|
+
) -> Participant | None:
|
|
1063
|
+
"""Accept an invitation.
|
|
1064
|
+
|
|
1065
|
+
Args:
|
|
1066
|
+
invite_id: Invitation ID
|
|
1067
|
+
user_id: ID of accepting user
|
|
1068
|
+
user_name: Name of accepting user
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
Added participant or None
|
|
1072
|
+
"""
|
|
1073
|
+
invitation = self._invitations.get(invite_id)
|
|
1074
|
+
if not invitation:
|
|
1075
|
+
return None
|
|
1076
|
+
|
|
1077
|
+
# Check expiration
|
|
1078
|
+
if invitation.expires_at and datetime.now() > invitation.expires_at:
|
|
1079
|
+
return None
|
|
1080
|
+
|
|
1081
|
+
if invitation.accepted:
|
|
1082
|
+
return None
|
|
1083
|
+
|
|
1084
|
+
# Add participant
|
|
1085
|
+
participant = self.collab.add_participant(
|
|
1086
|
+
invitation.session_id,
|
|
1087
|
+
user_id,
|
|
1088
|
+
user_name,
|
|
1089
|
+
email=invitation.invitee_email,
|
|
1090
|
+
role=invitation.role,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
if participant:
|
|
1094
|
+
invitation.accepted = True
|
|
1095
|
+
|
|
1096
|
+
return participant
|
|
1097
|
+
|
|
1098
|
+
def get_pending_invitations(self, session_id: str) -> list[Invitation]:
|
|
1099
|
+
"""Get pending invitations for a session.
|
|
1100
|
+
|
|
1101
|
+
Args:
|
|
1102
|
+
session_id: Session ID
|
|
1103
|
+
|
|
1104
|
+
Returns:
|
|
1105
|
+
List of pending invitations
|
|
1106
|
+
"""
|
|
1107
|
+
now = datetime.now()
|
|
1108
|
+
return [
|
|
1109
|
+
inv
|
|
1110
|
+
for inv in self._invitations.values()
|
|
1111
|
+
if inv.session_id == session_id
|
|
1112
|
+
and not inv.accepted
|
|
1113
|
+
and (inv.expires_at is None or inv.expires_at > now)
|
|
1114
|
+
]
|