gobby 0.2.5__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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session routes for Gobby HTTP server.
|
|
3
|
+
|
|
4
|
+
Provides session registration, listing, lookup, and update endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
12
|
+
|
|
13
|
+
from gobby.servers.models import SessionRegisterRequest
|
|
14
|
+
from gobby.utils.metrics import get_metrics_collector
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gobby.servers.http import HTTPServer
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_sessions_router(server: "HTTPServer") -> APIRouter:
|
|
23
|
+
"""
|
|
24
|
+
Create sessions router with endpoints bound to server instance.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
server: HTTPServer instance for accessing state and dependencies
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Configured APIRouter with session endpoints
|
|
31
|
+
"""
|
|
32
|
+
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
|
33
|
+
metrics = get_metrics_collector()
|
|
34
|
+
|
|
35
|
+
@router.post("/register")
|
|
36
|
+
async def register_session(request_data: SessionRegisterRequest) -> dict[str, Any]:
|
|
37
|
+
"""
|
|
38
|
+
Register session metadata in local storage.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
request_data: Session registration parameters
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Registration confirmation with session ID
|
|
45
|
+
"""
|
|
46
|
+
metrics.inc_counter("http_requests_total")
|
|
47
|
+
metrics.inc_counter("session_registrations_total")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
if server.session_manager is None:
|
|
51
|
+
raise HTTPException(status_code=503, detail="Session manager not available")
|
|
52
|
+
|
|
53
|
+
# Get machine_id from request or generate
|
|
54
|
+
machine_id = request_data.machine_id
|
|
55
|
+
if not machine_id:
|
|
56
|
+
from gobby.utils.machine_id import get_machine_id
|
|
57
|
+
|
|
58
|
+
machine_id = get_machine_id()
|
|
59
|
+
|
|
60
|
+
if not machine_id:
|
|
61
|
+
# Should unlikely happen if get_machine_id works, but type safe
|
|
62
|
+
machine_id = "unknown-machine"
|
|
63
|
+
|
|
64
|
+
# Extract git branch if project path exists but git_branch not provided
|
|
65
|
+
git_branch = request_data.git_branch
|
|
66
|
+
if request_data.project_path and not git_branch:
|
|
67
|
+
from gobby.utils.git import get_git_metadata
|
|
68
|
+
|
|
69
|
+
git_metadata = get_git_metadata(request_data.project_path)
|
|
70
|
+
if git_metadata.get("git_branch"):
|
|
71
|
+
git_branch = git_metadata.get("git_branch")
|
|
72
|
+
|
|
73
|
+
# Resolve project_id from cwd if not provided
|
|
74
|
+
project_id = server._resolve_project_id(request_data.project_id, request_data.cwd)
|
|
75
|
+
|
|
76
|
+
# Register session in local storage
|
|
77
|
+
session = server.session_manager.register(
|
|
78
|
+
external_id=request_data.external_id,
|
|
79
|
+
machine_id=machine_id,
|
|
80
|
+
source=request_data.source or "Claude Code",
|
|
81
|
+
project_id=project_id,
|
|
82
|
+
jsonl_path=request_data.jsonl_path,
|
|
83
|
+
title=request_data.title,
|
|
84
|
+
git_branch=git_branch,
|
|
85
|
+
parent_session_id=request_data.parent_session_id,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"status": "registered",
|
|
90
|
+
"external_id": request_data.external_id,
|
|
91
|
+
"id": session.id,
|
|
92
|
+
"machine_id": machine_id,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
except HTTPException:
|
|
96
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
except ValueError as e:
|
|
100
|
+
# ValueError from _resolve_project_id when project not initialized
|
|
101
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
102
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
106
|
+
logger.error(f"Error registering session: {e}", exc_info=True)
|
|
107
|
+
raise HTTPException(
|
|
108
|
+
status_code=500, detail="Internal server error while registering session"
|
|
109
|
+
) from e
|
|
110
|
+
|
|
111
|
+
@router.get("")
|
|
112
|
+
async def list_sessions(
|
|
113
|
+
project_id: str | None = None,
|
|
114
|
+
status: str | None = None,
|
|
115
|
+
source: str | None = None,
|
|
116
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""
|
|
119
|
+
List sessions with optional filtering and message counts.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
project_id: Filter by project ID
|
|
123
|
+
status: Filter by status (active, archived, etc)
|
|
124
|
+
source: Filter by source (Claude Code, Gemini, etc)
|
|
125
|
+
limit: Max results (default 100)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of session objects with message counts
|
|
129
|
+
"""
|
|
130
|
+
metrics.inc_counter("http_requests_total")
|
|
131
|
+
start_time = time.perf_counter()
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
if server.session_manager is None:
|
|
135
|
+
raise HTTPException(status_code=503, detail="Session manager not available")
|
|
136
|
+
|
|
137
|
+
sessions = server.session_manager.list(
|
|
138
|
+
project_id=project_id,
|
|
139
|
+
status=status,
|
|
140
|
+
source=source,
|
|
141
|
+
limit=limit,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Fetch message counts if message manager is available
|
|
145
|
+
message_counts = {}
|
|
146
|
+
if server.message_manager:
|
|
147
|
+
try:
|
|
148
|
+
message_counts = await server.message_manager.get_all_counts()
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.warning(f"Failed to fetch message counts: {e}")
|
|
151
|
+
|
|
152
|
+
# Enrich sessions with counts
|
|
153
|
+
session_list = []
|
|
154
|
+
for session in sessions:
|
|
155
|
+
session_data = session.to_dict()
|
|
156
|
+
session_data["message_count"] = message_counts.get(session.id, 0)
|
|
157
|
+
session_list.append(session_data)
|
|
158
|
+
|
|
159
|
+
response_time_ms = (time.perf_counter() - start_time) * 1000
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"sessions": session_list,
|
|
163
|
+
"count": len(session_list),
|
|
164
|
+
"response_time_ms": response_time_ms,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
except HTTPException:
|
|
168
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
169
|
+
raise
|
|
170
|
+
except Exception as e:
|
|
171
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
172
|
+
logger.error(f"Error listing sessions: {e}", exc_info=True)
|
|
173
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
174
|
+
|
|
175
|
+
@router.get("/{session_id}")
|
|
176
|
+
async def sessions_get(session_id: str) -> dict[str, Any]:
|
|
177
|
+
"""
|
|
178
|
+
Get session by ID from local storage.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
session_id: Session ID (UUID)
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Session data
|
|
185
|
+
"""
|
|
186
|
+
start_time = time.perf_counter()
|
|
187
|
+
metrics.inc_counter("http_requests_total")
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
if server.session_manager is None:
|
|
191
|
+
raise HTTPException(status_code=503, detail="Session manager not available")
|
|
192
|
+
|
|
193
|
+
session = server.session_manager.get(session_id)
|
|
194
|
+
|
|
195
|
+
if session is None:
|
|
196
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
197
|
+
|
|
198
|
+
response_time_ms = (time.perf_counter() - start_time) * 1000
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"status": "success",
|
|
202
|
+
"session": session.to_dict(),
|
|
203
|
+
"response_time_ms": response_time_ms,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
except HTTPException:
|
|
207
|
+
raise
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Sessions get error: {e}", exc_info=True)
|
|
210
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
211
|
+
|
|
212
|
+
@router.get("/{session_id}/messages")
|
|
213
|
+
async def sessions_get_messages(
|
|
214
|
+
session_id: str,
|
|
215
|
+
limit: int = 100,
|
|
216
|
+
offset: int = 0,
|
|
217
|
+
role: str | None = None,
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
"""
|
|
220
|
+
Get messages for a session.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
session_id: Session ID
|
|
224
|
+
limit: Max messages to return (default 100)
|
|
225
|
+
offset: Pagination offset
|
|
226
|
+
role: Filter by role (user, assistant, tool)
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
List of messages and total count key
|
|
230
|
+
"""
|
|
231
|
+
start_time = time.perf_counter()
|
|
232
|
+
metrics.inc_counter("http_requests_total")
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
if server.message_manager is None:
|
|
236
|
+
raise HTTPException(status_code=503, detail="Message manager not available")
|
|
237
|
+
|
|
238
|
+
messages = await server.message_manager.get_messages(
|
|
239
|
+
session_id=session_id, limit=limit, offset=offset, role=role
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
count = await server.message_manager.count_messages(session_id)
|
|
243
|
+
response_time_ms = (time.perf_counter() - start_time) * 1000
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
"status": "success",
|
|
247
|
+
"messages": messages,
|
|
248
|
+
"total_count": count,
|
|
249
|
+
"response_time_ms": response_time_ms,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
except HTTPException:
|
|
253
|
+
raise
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.error(f"Get messages error: {e}", exc_info=True)
|
|
256
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
257
|
+
|
|
258
|
+
@router.post("/find_current")
|
|
259
|
+
async def find_current_session(request: Request) -> dict[str, Any]:
|
|
260
|
+
"""
|
|
261
|
+
Find current active session by composite key.
|
|
262
|
+
|
|
263
|
+
Uses composite key: external_id, machine_id, source, project_id
|
|
264
|
+
Accepts either project_id directly or cwd (which is resolved to project_id).
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
if server.session_manager is None:
|
|
268
|
+
raise HTTPException(status_code=503, detail="Session manager not available")
|
|
269
|
+
|
|
270
|
+
body = await request.json()
|
|
271
|
+
external_id = body.get("external_id")
|
|
272
|
+
machine_id = body.get("machine_id")
|
|
273
|
+
source = body.get("source")
|
|
274
|
+
project_id = body.get("project_id")
|
|
275
|
+
cwd = body.get("cwd")
|
|
276
|
+
|
|
277
|
+
if not external_id or not machine_id or not source:
|
|
278
|
+
raise HTTPException(
|
|
279
|
+
status_code=400,
|
|
280
|
+
detail="Required fields: external_id, machine_id, source",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Resolve project_id from cwd if not provided
|
|
284
|
+
if not project_id and cwd:
|
|
285
|
+
project_id = server._resolve_project_id(None, cwd)
|
|
286
|
+
|
|
287
|
+
if not project_id:
|
|
288
|
+
raise HTTPException(
|
|
289
|
+
status_code=400,
|
|
290
|
+
detail="Required: project_id or cwd (to resolve project)",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
session = server.session_manager.find_by_external_id(
|
|
294
|
+
external_id, machine_id, project_id, source
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if session is None:
|
|
298
|
+
return {"session": None}
|
|
299
|
+
|
|
300
|
+
return {"session": session.to_dict()}
|
|
301
|
+
|
|
302
|
+
except HTTPException:
|
|
303
|
+
raise
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.error(f"Find current session error: {e}", exc_info=True)
|
|
306
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
307
|
+
|
|
308
|
+
@router.post("/find_parent")
|
|
309
|
+
async def find_parent_session(request: Request) -> dict[str, Any]:
|
|
310
|
+
"""
|
|
311
|
+
Find parent session for handoff.
|
|
312
|
+
|
|
313
|
+
Looks for most recent session in same project with handoff_ready status.
|
|
314
|
+
Accepts either project_id directly or cwd (which is resolved to project_id).
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
if server.session_manager is None:
|
|
318
|
+
raise HTTPException(status_code=503, detail="Session manager not available")
|
|
319
|
+
|
|
320
|
+
body = await request.json()
|
|
321
|
+
machine_id = body.get("machine_id")
|
|
322
|
+
source = body.get("source")
|
|
323
|
+
project_id = body.get("project_id")
|
|
324
|
+
cwd = body.get("cwd")
|
|
325
|
+
|
|
326
|
+
if not source:
|
|
327
|
+
raise HTTPException(status_code=400, detail="Required field: source")
|
|
328
|
+
|
|
329
|
+
if not machine_id:
|
|
330
|
+
from gobby.utils.machine_id import get_machine_id
|
|
331
|
+
|
|
332
|
+
machine_id = get_machine_id()
|
|
333
|
+
|
|
334
|
+
if not machine_id:
|
|
335
|
+
machine_id = "unknown-machine"
|
|
336
|
+
|
|
337
|
+
# Resolve project_id from cwd if not provided
|
|
338
|
+
if not project_id:
|
|
339
|
+
if not cwd:
|
|
340
|
+
raise HTTPException(
|
|
341
|
+
status_code=400,
|
|
342
|
+
detail="Required field: project_id or cwd",
|
|
343
|
+
)
|
|
344
|
+
project_id = server._resolve_project_id(None, cwd)
|
|
345
|
+
|
|
346
|
+
session = server.session_manager.find_parent(
|
|
347
|
+
machine_id=machine_id,
|
|
348
|
+
source=source,
|
|
349
|
+
project_id=project_id,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if session is None:
|
|
353
|
+
return {"session": None}
|
|
354
|
+
|
|
355
|
+
return {"session": session.to_dict()}
|
|
356
|
+
|
|
357
|
+
except HTTPException:
|
|
358
|
+
raise
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.error(f"Find parent session error: {e}", exc_info=True)
|
|
361
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
362
|
+
|
|
363
|
+
@router.post("/update_status")
|
|
364
|
+
async def update_session_status(request: Request) -> dict[str, Any]:
|
|
365
|
+
"""
|
|
366
|
+
Update session status.
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
if server.session_manager is None:
|
|
370
|
+
raise HTTPException(status_code=503, detail="Session manager not available")
|
|
371
|
+
|
|
372
|
+
body = await request.json()
|
|
373
|
+
session_id = body.get("session_id")
|
|
374
|
+
status = body.get("status")
|
|
375
|
+
|
|
376
|
+
if not session_id or not status:
|
|
377
|
+
raise HTTPException(status_code=400, detail="Required fields: session_id, status")
|
|
378
|
+
|
|
379
|
+
session = server.session_manager.update_status(session_id, status)
|
|
380
|
+
|
|
381
|
+
if session is None:
|
|
382
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
383
|
+
|
|
384
|
+
return {"session": session.to_dict()}
|
|
385
|
+
|
|
386
|
+
except HTTPException:
|
|
387
|
+
raise
|
|
388
|
+
except Exception as e:
|
|
389
|
+
logger.error(f"Update session status error: {e}", exc_info=True)
|
|
390
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
391
|
+
|
|
392
|
+
@router.post("/update_summary")
|
|
393
|
+
async def update_session_summary(request: Request) -> dict[str, Any]:
|
|
394
|
+
"""
|
|
395
|
+
Update session summary path.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
if server.session_manager is None:
|
|
399
|
+
raise HTTPException(status_code=503, detail="Session manager not available")
|
|
400
|
+
|
|
401
|
+
body = await request.json()
|
|
402
|
+
session_id = body.get("session_id")
|
|
403
|
+
summary_path = body.get("summary_path")
|
|
404
|
+
|
|
405
|
+
if not session_id or not summary_path:
|
|
406
|
+
raise HTTPException(
|
|
407
|
+
status_code=400, detail="Required fields: session_id, summary_path"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
session = server.session_manager.update_summary(session_id, summary_path)
|
|
411
|
+
|
|
412
|
+
if session is None:
|
|
413
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
414
|
+
|
|
415
|
+
return {"session": session.to_dict()}
|
|
416
|
+
|
|
417
|
+
except HTTPException:
|
|
418
|
+
raise
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.error(f"Update session summary error: {e}", exc_info=True)
|
|
421
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
422
|
+
|
|
423
|
+
@router.post("/{session_id}/stop")
|
|
424
|
+
async def stop_session(session_id: str, request: Request) -> dict[str, Any]:
|
|
425
|
+
"""
|
|
426
|
+
Signal a session to stop gracefully.
|
|
427
|
+
|
|
428
|
+
Allows external systems to request a graceful stop of an autonomous session.
|
|
429
|
+
The session will check for this signal and stop at the next opportunity.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
session_id: Session ID to stop
|
|
433
|
+
request: Request body with optional reason and source
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Stop signal confirmation
|
|
437
|
+
"""
|
|
438
|
+
metrics.inc_counter("http_requests_total")
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
# Get HookManager from app state
|
|
442
|
+
if not hasattr(request.app.state, "hook_manager"):
|
|
443
|
+
raise HTTPException(status_code=503, detail="Hook manager not available")
|
|
444
|
+
|
|
445
|
+
hook_manager = request.app.state.hook_manager
|
|
446
|
+
if not hasattr(hook_manager, "_stop_registry") or not hook_manager._stop_registry:
|
|
447
|
+
raise HTTPException(status_code=503, detail="Stop registry not available")
|
|
448
|
+
|
|
449
|
+
stop_registry = hook_manager._stop_registry
|
|
450
|
+
|
|
451
|
+
# Parse optional body parameters
|
|
452
|
+
body: dict[str, Any] = {}
|
|
453
|
+
try:
|
|
454
|
+
body = await request.json()
|
|
455
|
+
except Exception:
|
|
456
|
+
pass # nosec B110 - empty body is fine
|
|
457
|
+
|
|
458
|
+
reason = body.get("reason", "External stop request")
|
|
459
|
+
source = body.get("source", "http_api")
|
|
460
|
+
|
|
461
|
+
# Signal the stop
|
|
462
|
+
signal = stop_registry.signal_stop(
|
|
463
|
+
session_id=session_id,
|
|
464
|
+
reason=reason,
|
|
465
|
+
source=source,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
logger.info(f"Stop signal sent to session {session_id}: {reason}")
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
"status": "stop_signaled",
|
|
472
|
+
"session_id": session_id,
|
|
473
|
+
"signal_id": signal.signal_id,
|
|
474
|
+
"reason": signal.reason,
|
|
475
|
+
"source": signal.source,
|
|
476
|
+
"signaled_at": signal.signaled_at.isoformat(),
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
except HTTPException:
|
|
480
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
481
|
+
raise
|
|
482
|
+
except Exception as e:
|
|
483
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
484
|
+
logger.error(f"Error sending stop signal: {e}", exc_info=True)
|
|
485
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
486
|
+
|
|
487
|
+
@router.get("/{session_id}/stop")
|
|
488
|
+
async def get_stop_signal(session_id: str, request: Request) -> dict[str, Any]:
|
|
489
|
+
"""
|
|
490
|
+
Check if a session has a pending stop signal.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
session_id: Session ID to check
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
Stop signal status and details if present
|
|
497
|
+
"""
|
|
498
|
+
metrics.inc_counter("http_requests_total")
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
# Get HookManager from app state
|
|
502
|
+
if not hasattr(request.app.state, "hook_manager"):
|
|
503
|
+
raise HTTPException(status_code=503, detail="Hook manager not available")
|
|
504
|
+
|
|
505
|
+
hook_manager = request.app.state.hook_manager
|
|
506
|
+
if not hasattr(hook_manager, "_stop_registry") or not hook_manager._stop_registry:
|
|
507
|
+
raise HTTPException(status_code=503, detail="Stop registry not available")
|
|
508
|
+
|
|
509
|
+
stop_registry = hook_manager._stop_registry
|
|
510
|
+
|
|
511
|
+
signal = stop_registry.get_signal(session_id)
|
|
512
|
+
|
|
513
|
+
if signal is None:
|
|
514
|
+
return {
|
|
515
|
+
"has_signal": False,
|
|
516
|
+
"session_id": session_id,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
"has_signal": True,
|
|
521
|
+
"session_id": session_id,
|
|
522
|
+
"signal_id": signal.signal_id,
|
|
523
|
+
"reason": signal.reason,
|
|
524
|
+
"source": signal.source,
|
|
525
|
+
"signaled_at": signal.signaled_at.isoformat(),
|
|
526
|
+
"acknowledged": signal.acknowledged,
|
|
527
|
+
"acknowledged_at": (
|
|
528
|
+
signal.acknowledged_at.isoformat() if signal.acknowledged_at else None
|
|
529
|
+
),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
except HTTPException:
|
|
533
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
534
|
+
raise
|
|
535
|
+
except Exception as e:
|
|
536
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
537
|
+
logger.error(f"Error checking stop signal: {e}", exc_info=True)
|
|
538
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
539
|
+
|
|
540
|
+
@router.delete("/{session_id}/stop")
|
|
541
|
+
async def clear_stop_signal(session_id: str, request: Request) -> dict[str, Any]:
|
|
542
|
+
"""
|
|
543
|
+
Clear a stop signal for a session.
|
|
544
|
+
|
|
545
|
+
Useful for resetting a session's stop state after handling.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
session_id: Session ID to clear signal for
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
Confirmation of signal cleared
|
|
552
|
+
"""
|
|
553
|
+
metrics.inc_counter("http_requests_total")
|
|
554
|
+
|
|
555
|
+
try:
|
|
556
|
+
# Get HookManager from app state
|
|
557
|
+
if not hasattr(request.app.state, "hook_manager"):
|
|
558
|
+
raise HTTPException(status_code=503, detail="Hook manager not available")
|
|
559
|
+
|
|
560
|
+
hook_manager = request.app.state.hook_manager
|
|
561
|
+
if not hasattr(hook_manager, "_stop_registry") or not hook_manager._stop_registry:
|
|
562
|
+
raise HTTPException(status_code=503, detail="Stop registry not available")
|
|
563
|
+
|
|
564
|
+
stop_registry = hook_manager._stop_registry
|
|
565
|
+
|
|
566
|
+
cleared = stop_registry.clear(session_id)
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
"status": "cleared" if cleared else "no_signal",
|
|
570
|
+
"session_id": session_id,
|
|
571
|
+
"was_present": cleared,
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
except HTTPException:
|
|
575
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
576
|
+
raise
|
|
577
|
+
except Exception as e:
|
|
578
|
+
metrics.inc_counter("http_requests_errors_total")
|
|
579
|
+
logger.error(f"Error clearing stop signal: {e}", exc_info=True)
|
|
580
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
581
|
+
|
|
582
|
+
return router
|