agentpool 2.1.9__py3-none-any.whl → 2.5.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.
- acp/__init__.py +13 -4
- acp/acp_requests.py +20 -77
- acp/agent/connection.py +8 -0
- acp/agent/implementations/debug_server/debug_server.py +6 -2
- acp/agent/protocol.py +6 -0
- acp/bridge/README.md +15 -2
- acp/bridge/__init__.py +3 -2
- acp/bridge/__main__.py +60 -19
- acp/bridge/ws_server.py +173 -0
- acp/bridge/ws_server_cli.py +89 -0
- acp/client/connection.py +38 -29
- acp/client/implementations/default_client.py +3 -2
- acp/client/implementations/headless_client.py +2 -2
- acp/connection.py +2 -2
- acp/notifications.py +20 -50
- acp/schema/__init__.py +2 -0
- acp/schema/agent_responses.py +21 -0
- acp/schema/client_requests.py +3 -3
- acp/schema/session_state.py +63 -29
- acp/stdio.py +39 -9
- acp/task/supervisor.py +2 -2
- acp/transports.py +362 -2
- acp/utils.py +17 -4
- agentpool/__init__.py +6 -1
- agentpool/agents/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +407 -277
- agentpool/agents/acp_agent/acp_converters.py +196 -38
- agentpool/agents/acp_agent/client_handler.py +191 -26
- agentpool/agents/acp_agent/session_state.py +17 -6
- agentpool/agents/agent.py +607 -572
- agentpool/agents/agui_agent/__init__.py +0 -2
- agentpool/agents/agui_agent/agui_agent.py +176 -110
- agentpool/agents/agui_agent/agui_converters.py +0 -131
- agentpool/agents/agui_agent/helpers.py +3 -4
- agentpool/agents/base_agent.py +632 -17
- agentpool/agents/claude_code_agent/FORKING.md +191 -0
- agentpool/agents/claude_code_agent/__init__.py +13 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
- agentpool/agents/claude_code_agent/converters.py +74 -143
- agentpool/agents/claude_code_agent/history.py +474 -0
- agentpool/agents/claude_code_agent/models.py +77 -0
- agentpool/agents/claude_code_agent/static_info.py +100 -0
- agentpool/agents/claude_code_agent/usage.py +242 -0
- agentpool/agents/context.py +40 -0
- agentpool/agents/events/__init__.py +24 -0
- agentpool/agents/events/builtin_handlers.py +67 -1
- agentpool/agents/events/event_emitter.py +32 -2
- agentpool/agents/events/events.py +104 -3
- agentpool/agents/events/infer_info.py +145 -0
- agentpool/agents/events/processors.py +254 -0
- agentpool/agents/interactions.py +41 -6
- agentpool/agents/modes.py +67 -0
- agentpool/agents/slashed_agent.py +5 -4
- agentpool/agents/tool_call_accumulator.py +213 -0
- agentpool/agents/tool_wrapping.py +18 -6
- agentpool/common_types.py +56 -21
- agentpool/config_resources/__init__.py +38 -1
- agentpool/config_resources/acp_assistant.yml +2 -2
- agentpool/config_resources/agents.yml +3 -0
- agentpool/config_resources/agents_template.yml +1 -0
- agentpool/config_resources/claude_code_agent.yml +10 -6
- agentpool/config_resources/external_acp_agents.yml +2 -1
- agentpool/delegation/base_team.py +4 -30
- agentpool/delegation/pool.py +136 -289
- agentpool/delegation/team.py +58 -57
- agentpool/delegation/teamrun.py +51 -55
- agentpool/diagnostics/__init__.py +53 -0
- agentpool/diagnostics/lsp_manager.py +1593 -0
- agentpool/diagnostics/lsp_proxy.py +41 -0
- agentpool/diagnostics/lsp_proxy_script.py +229 -0
- agentpool/diagnostics/models.py +398 -0
- agentpool/functional/run.py +10 -4
- agentpool/mcp_server/__init__.py +0 -2
- agentpool/mcp_server/client.py +76 -32
- agentpool/mcp_server/conversions.py +54 -13
- agentpool/mcp_server/manager.py +34 -54
- agentpool/mcp_server/registries/official_registry_client.py +35 -1
- agentpool/mcp_server/tool_bridge.py +186 -139
- agentpool/messaging/__init__.py +0 -2
- agentpool/messaging/compaction.py +72 -197
- agentpool/messaging/connection_manager.py +11 -10
- agentpool/messaging/event_manager.py +5 -5
- agentpool/messaging/message_container.py +6 -30
- agentpool/messaging/message_history.py +99 -8
- agentpool/messaging/messagenode.py +52 -14
- agentpool/messaging/messages.py +54 -35
- agentpool/messaging/processing.py +12 -22
- agentpool/models/__init__.py +1 -1
- agentpool/models/acp_agents/base.py +6 -24
- agentpool/models/acp_agents/mcp_capable.py +126 -157
- agentpool/models/acp_agents/non_mcp.py +129 -95
- agentpool/models/agents.py +98 -76
- agentpool/models/agui_agents.py +1 -1
- agentpool/models/claude_code_agents.py +144 -19
- agentpool/models/file_parsing.py +0 -1
- agentpool/models/manifest.py +113 -50
- agentpool/prompts/conversion_manager.py +1 -1
- agentpool/prompts/prompts.py +5 -2
- agentpool/repomap.py +1 -1
- agentpool/resource_providers/__init__.py +11 -1
- agentpool/resource_providers/aggregating.py +56 -5
- agentpool/resource_providers/base.py +70 -4
- agentpool/resource_providers/codemode/code_executor.py +72 -5
- agentpool/resource_providers/codemode/helpers.py +2 -2
- agentpool/resource_providers/codemode/provider.py +64 -12
- agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
- agentpool/resource_providers/codemode/remote_provider.py +9 -12
- agentpool/resource_providers/filtering.py +3 -1
- agentpool/resource_providers/mcp_provider.py +89 -12
- agentpool/resource_providers/plan_provider.py +228 -46
- agentpool/resource_providers/pool.py +7 -3
- agentpool/resource_providers/resource_info.py +111 -0
- agentpool/resource_providers/static.py +4 -2
- agentpool/sessions/__init__.py +4 -1
- agentpool/sessions/manager.py +33 -5
- agentpool/sessions/models.py +59 -6
- agentpool/sessions/protocol.py +28 -0
- agentpool/sessions/session.py +11 -55
- agentpool/skills/registry.py +13 -8
- agentpool/storage/manager.py +572 -49
- agentpool/talk/registry.py +4 -4
- agentpool/talk/talk.py +9 -10
- agentpool/testing.py +538 -20
- agentpool/tool_impls/__init__.py +6 -0
- agentpool/tool_impls/agent_cli/__init__.py +42 -0
- agentpool/tool_impls/agent_cli/tool.py +95 -0
- agentpool/tool_impls/bash/__init__.py +64 -0
- agentpool/tool_impls/bash/helpers.py +35 -0
- agentpool/tool_impls/bash/tool.py +171 -0
- agentpool/tool_impls/delete_path/__init__.py +70 -0
- agentpool/tool_impls/delete_path/tool.py +142 -0
- agentpool/tool_impls/download_file/__init__.py +80 -0
- agentpool/tool_impls/download_file/tool.py +183 -0
- agentpool/tool_impls/execute_code/__init__.py +55 -0
- agentpool/tool_impls/execute_code/tool.py +163 -0
- agentpool/tool_impls/grep/__init__.py +80 -0
- agentpool/tool_impls/grep/tool.py +200 -0
- agentpool/tool_impls/list_directory/__init__.py +73 -0
- agentpool/tool_impls/list_directory/tool.py +197 -0
- agentpool/tool_impls/question/__init__.py +42 -0
- agentpool/tool_impls/question/tool.py +127 -0
- agentpool/tool_impls/read/__init__.py +104 -0
- agentpool/tool_impls/read/tool.py +305 -0
- agentpool/tools/__init__.py +2 -1
- agentpool/tools/base.py +114 -34
- agentpool/tools/manager.py +57 -1
- agentpool/ui/base.py +2 -2
- agentpool/ui/mock_provider.py +2 -2
- agentpool/ui/stdlib_provider.py +2 -2
- agentpool/utils/file_watcher.py +269 -0
- agentpool/utils/identifiers.py +121 -0
- agentpool/utils/pydantic_ai_helpers.py +46 -0
- agentpool/utils/streams.py +616 -2
- agentpool/utils/subprocess_utils.py +155 -0
- agentpool/utils/token_breakdown.py +461 -0
- agentpool/vfs_registry.py +7 -2
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
- agentpool-2.5.0.dist-info/RECORD +579 -0
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +24 -0
- agentpool_cli/create.py +1 -1
- agentpool_cli/serve_acp.py +100 -21
- agentpool_cli/serve_agui.py +87 -0
- agentpool_cli/serve_opencode.py +119 -0
- agentpool_cli/ui.py +557 -0
- agentpool_commands/__init__.py +42 -5
- agentpool_commands/agents.py +75 -2
- agentpool_commands/history.py +62 -0
- agentpool_commands/mcp.py +176 -0
- agentpool_commands/models.py +56 -3
- agentpool_commands/pool.py +260 -0
- agentpool_commands/session.py +1 -1
- agentpool_commands/text_sharing/__init__.py +119 -0
- agentpool_commands/text_sharing/base.py +123 -0
- agentpool_commands/text_sharing/github_gist.py +80 -0
- agentpool_commands/text_sharing/opencode.py +462 -0
- agentpool_commands/text_sharing/paste_rs.py +59 -0
- agentpool_commands/text_sharing/pastebin.py +116 -0
- agentpool_commands/text_sharing/shittycodingagent.py +112 -0
- agentpool_commands/tools.py +57 -0
- agentpool_commands/utils.py +80 -30
- agentpool_config/__init__.py +30 -2
- agentpool_config/agentpool_tools.py +498 -0
- agentpool_config/builtin_tools.py +77 -22
- agentpool_config/commands.py +24 -1
- agentpool_config/compaction.py +258 -0
- agentpool_config/converters.py +1 -1
- agentpool_config/event_handlers.py +42 -0
- agentpool_config/events.py +1 -1
- agentpool_config/forward_targets.py +1 -4
- agentpool_config/jinja.py +3 -3
- agentpool_config/mcp_server.py +132 -6
- agentpool_config/nodes.py +1 -1
- agentpool_config/observability.py +44 -0
- agentpool_config/session.py +0 -3
- agentpool_config/storage.py +82 -38
- agentpool_config/task.py +3 -3
- agentpool_config/tools.py +11 -22
- agentpool_config/toolsets.py +109 -233
- agentpool_server/a2a_server/agent_worker.py +307 -0
- agentpool_server/a2a_server/server.py +23 -18
- agentpool_server/acp_server/acp_agent.py +234 -181
- agentpool_server/acp_server/commands/acp_commands.py +151 -156
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
- agentpool_server/acp_server/event_converter.py +651 -0
- agentpool_server/acp_server/input_provider.py +53 -10
- agentpool_server/acp_server/server.py +24 -90
- agentpool_server/acp_server/session.py +173 -331
- agentpool_server/acp_server/session_manager.py +8 -34
- agentpool_server/agui_server/server.py +3 -1
- agentpool_server/mcp_server/server.py +5 -2
- agentpool_server/opencode_server/.rules +95 -0
- agentpool_server/opencode_server/ENDPOINTS.md +401 -0
- agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
- agentpool_server/opencode_server/__init__.py +19 -0
- agentpool_server/opencode_server/command_validation.py +172 -0
- agentpool_server/opencode_server/converters.py +975 -0
- agentpool_server/opencode_server/dependencies.py +24 -0
- agentpool_server/opencode_server/input_provider.py +421 -0
- agentpool_server/opencode_server/models/__init__.py +250 -0
- agentpool_server/opencode_server/models/agent.py +53 -0
- agentpool_server/opencode_server/models/app.py +72 -0
- agentpool_server/opencode_server/models/base.py +26 -0
- agentpool_server/opencode_server/models/common.py +23 -0
- agentpool_server/opencode_server/models/config.py +37 -0
- agentpool_server/opencode_server/models/events.py +821 -0
- agentpool_server/opencode_server/models/file.py +88 -0
- agentpool_server/opencode_server/models/mcp.py +44 -0
- agentpool_server/opencode_server/models/message.py +179 -0
- agentpool_server/opencode_server/models/parts.py +323 -0
- agentpool_server/opencode_server/models/provider.py +81 -0
- agentpool_server/opencode_server/models/pty.py +43 -0
- agentpool_server/opencode_server/models/question.py +56 -0
- agentpool_server/opencode_server/models/session.py +111 -0
- agentpool_server/opencode_server/routes/__init__.py +29 -0
- agentpool_server/opencode_server/routes/agent_routes.py +473 -0
- agentpool_server/opencode_server/routes/app_routes.py +202 -0
- agentpool_server/opencode_server/routes/config_routes.py +302 -0
- agentpool_server/opencode_server/routes/file_routes.py +571 -0
- agentpool_server/opencode_server/routes/global_routes.py +94 -0
- agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
- agentpool_server/opencode_server/routes/message_routes.py +761 -0
- agentpool_server/opencode_server/routes/permission_routes.py +63 -0
- agentpool_server/opencode_server/routes/pty_routes.py +300 -0
- agentpool_server/opencode_server/routes/question_routes.py +128 -0
- agentpool_server/opencode_server/routes/session_routes.py +1276 -0
- agentpool_server/opencode_server/routes/tui_routes.py +139 -0
- agentpool_server/opencode_server/server.py +475 -0
- agentpool_server/opencode_server/state.py +151 -0
- agentpool_server/opencode_server/time_utils.py +8 -0
- agentpool_storage/__init__.py +12 -0
- agentpool_storage/base.py +184 -2
- agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
- agentpool_storage/claude_provider/__init__.py +42 -0
- agentpool_storage/claude_provider/provider.py +1089 -0
- agentpool_storage/file_provider.py +278 -15
- agentpool_storage/memory_provider.py +193 -12
- agentpool_storage/models.py +3 -0
- agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
- agentpool_storage/opencode_provider/__init__.py +16 -0
- agentpool_storage/opencode_provider/helpers.py +414 -0
- agentpool_storage/opencode_provider/provider.py +895 -0
- agentpool_storage/project_store.py +325 -0
- agentpool_storage/session_store.py +26 -6
- agentpool_storage/sql_provider/__init__.py +4 -2
- agentpool_storage/sql_provider/models.py +48 -0
- agentpool_storage/sql_provider/sql_provider.py +269 -3
- agentpool_storage/sql_provider/utils.py +12 -13
- agentpool_storage/zed_provider/__init__.py +16 -0
- agentpool_storage/zed_provider/helpers.py +281 -0
- agentpool_storage/zed_provider/models.py +130 -0
- agentpool_storage/zed_provider/provider.py +442 -0
- agentpool_storage/zed_provider.py +803 -0
- agentpool_toolsets/__init__.py +0 -2
- agentpool_toolsets/builtin/__init__.py +2 -12
- agentpool_toolsets/builtin/code.py +96 -57
- agentpool_toolsets/builtin/debug.py +118 -48
- agentpool_toolsets/builtin/execution_environment.py +115 -230
- agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
- agentpool_toolsets/builtin/skills.py +9 -4
- agentpool_toolsets/builtin/subagent_tools.py +64 -51
- agentpool_toolsets/builtin/workers.py +4 -2
- agentpool_toolsets/composio_toolset.py +2 -2
- agentpool_toolsets/entry_points.py +3 -1
- agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
- agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
- agentpool_toolsets/fsspec_toolset/grep.py +99 -7
- agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
- agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
- agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
- agentpool_toolsets/mcp_discovery/__init__.py +5 -0
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +511 -0
- agentpool_toolsets/mcp_run_toolset.py +87 -12
- agentpool_toolsets/notifications.py +33 -33
- agentpool_toolsets/openapi.py +3 -1
- agentpool_toolsets/search_toolset.py +3 -1
- agentpool-2.1.9.dist-info/RECORD +0 -474
- agentpool_config/resources.py +0 -33
- agentpool_server/acp_server/acp_tools.py +0 -43
- agentpool_server/acp_server/commands/spawn.py +0 -210
- agentpool_storage/text_log_provider.py +0 -275
- agentpool_toolsets/builtin/agent_management.py +0 -239
- agentpool_toolsets/builtin/chain.py +0 -288
- agentpool_toolsets/builtin/history.py +0 -36
- agentpool_toolsets/builtin/integration.py +0 -85
- agentpool_toolsets/builtin/tool_management.py +0 -90
- agentpool_toolsets/builtin/user_interaction.py +0 -52
- agentpool_toolsets/semantic_memory_toolset.py +0 -536
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""File operation routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import fnmatch
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
14
|
+
|
|
15
|
+
from agentpool_server.opencode_server.dependencies import StateDep
|
|
16
|
+
from agentpool_server.opencode_server.models import (
|
|
17
|
+
FileContent,
|
|
18
|
+
FileNode,
|
|
19
|
+
FindMatch,
|
|
20
|
+
Symbol,
|
|
21
|
+
)
|
|
22
|
+
from agentpool_server.opencode_server.models.file import SubmatchInfo
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from fsspec.asyn import AsyncFileSystem
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
router = APIRouter(tags=["file"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Directories to skip when searching
|
|
33
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".tox", "dist", "build"}
|
|
34
|
+
|
|
35
|
+
# Sensitive files that should never be exposed via the API
|
|
36
|
+
BLOCKED_FILES = {".env", ".env.local", ".env.production", ".env.development", ".env.test"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_path(root: Path, user_path: str) -> Path:
|
|
40
|
+
"""Validate and resolve a user-provided path, ensuring it stays within root.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
root: The root directory (working_dir) that paths must stay within.
|
|
44
|
+
user_path: The user-provided relative path.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The resolved absolute path that is guaranteed to be within root.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
HTTPException: If the path escapes root or is blocked.
|
|
51
|
+
"""
|
|
52
|
+
# Resolve the root to handle any symlinks in the root itself
|
|
53
|
+
resolved_root = root.resolve()
|
|
54
|
+
|
|
55
|
+
# Join and resolve the full path (this handles ../, symlinks, etc.)
|
|
56
|
+
target = (root / user_path).resolve()
|
|
57
|
+
|
|
58
|
+
# Check that the resolved path is within the resolved root
|
|
59
|
+
try:
|
|
60
|
+
target.relative_to(resolved_root)
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=403,
|
|
64
|
+
detail="Access denied: path escapes project directory",
|
|
65
|
+
) from None
|
|
66
|
+
|
|
67
|
+
# Check for blocked files
|
|
68
|
+
if target.name in BLOCKED_FILES:
|
|
69
|
+
raise HTTPException(
|
|
70
|
+
status_code=403,
|
|
71
|
+
detail=f"Access denied: {target.name} files are protected",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return target
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_path_str(root: str, user_path: str) -> str:
|
|
78
|
+
"""Validate path for fsspec filesystem (string-based).
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
root: The root directory path as string.
|
|
82
|
+
user_path: The user-provided relative path.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The validated absolute path as string.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
HTTPException: If the path escapes root or is blocked.
|
|
89
|
+
"""
|
|
90
|
+
validated = _validate_path(Path(root), user_path)
|
|
91
|
+
return str(validated)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_fs(state: StateDep) -> tuple[AsyncFileSystem, str] | None:
|
|
95
|
+
"""Get the fsspec filesystem from the agent's environment if available.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Tuple of (filesystem, base_path) or None if not available.
|
|
99
|
+
base_path is the root directory to use for operations.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
fs = state.agent.env.get_fs()
|
|
103
|
+
# Use env's cwd if set, otherwise use state.working_dir
|
|
104
|
+
env = state.agent.env
|
|
105
|
+
base_path = env.cwd or state.working_dir
|
|
106
|
+
except NotImplementedError:
|
|
107
|
+
return None
|
|
108
|
+
else:
|
|
109
|
+
return (fs, base_path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_local_fs(fs: AsyncFileSystem) -> bool:
|
|
113
|
+
"""Check if filesystem is a local filesystem."""
|
|
114
|
+
return getattr(fs, "local_file", False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _has_ripgrep() -> bool:
|
|
118
|
+
"""Check if ripgrep is available."""
|
|
119
|
+
return shutil.which("rg") is not None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _search_with_ripgrep(
|
|
123
|
+
pattern: str,
|
|
124
|
+
base_path: str,
|
|
125
|
+
max_matches: int = 100,
|
|
126
|
+
) -> list[FindMatch]:
|
|
127
|
+
"""Search using ripgrep for better performance on local filesystems.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
pattern: Regex pattern to search for.
|
|
131
|
+
base_path: Directory to search in.
|
|
132
|
+
max_matches: Maximum number of matches to return.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of FindMatch objects.
|
|
136
|
+
"""
|
|
137
|
+
# Build ripgrep command with JSON output
|
|
138
|
+
cmd = [
|
|
139
|
+
"rg",
|
|
140
|
+
"--json",
|
|
141
|
+
"--max-count",
|
|
142
|
+
str(max_matches),
|
|
143
|
+
"--no-binary",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# Add exclude patterns for SKIP_DIRS
|
|
147
|
+
for skip_dir in SKIP_DIRS:
|
|
148
|
+
cmd.extend(["--glob", f"!{skip_dir}/"])
|
|
149
|
+
|
|
150
|
+
cmd.extend(["-e", pattern, base_path])
|
|
151
|
+
|
|
152
|
+
# Run ripgrep asynchronously
|
|
153
|
+
proc = await asyncio.create_subprocess_exec(
|
|
154
|
+
*cmd,
|
|
155
|
+
stdout=asyncio.subprocess.PIPE,
|
|
156
|
+
stderr=asyncio.subprocess.PIPE,
|
|
157
|
+
)
|
|
158
|
+
stdout, _ = await proc.communicate()
|
|
159
|
+
|
|
160
|
+
matches: list[FindMatch] = []
|
|
161
|
+
base_path_prefix = base_path.rstrip("/") + "/"
|
|
162
|
+
|
|
163
|
+
for line in stdout.decode("utf-8", errors="replace").splitlines():
|
|
164
|
+
if not line.strip():
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
data = json.loads(line)
|
|
168
|
+
if data.get("type") != "match":
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
match_data = data.get("data", {})
|
|
172
|
+
path = match_data.get("path", {}).get("text", "")
|
|
173
|
+
line_number = match_data.get("line_number", 0)
|
|
174
|
+
line_text = match_data.get("lines", {}).get("text", "").rstrip("\n")
|
|
175
|
+
absolute_offset = match_data.get("absolute_offset", 0)
|
|
176
|
+
|
|
177
|
+
# Convert to relative path
|
|
178
|
+
rel_path = path[len(base_path_prefix) :] if path.startswith(base_path_prefix) else path
|
|
179
|
+
|
|
180
|
+
# Extract submatches
|
|
181
|
+
submatches = []
|
|
182
|
+
for sm in match_data.get("submatches", []):
|
|
183
|
+
match_text = sm.get("match", {}).get("text", "")
|
|
184
|
+
start = sm.get("start", 0)
|
|
185
|
+
end = sm.get("end", 0)
|
|
186
|
+
submatches.append(SubmatchInfo.create(match_text, start, end))
|
|
187
|
+
|
|
188
|
+
matches.append(
|
|
189
|
+
FindMatch.create(
|
|
190
|
+
path=rel_path,
|
|
191
|
+
lines=line_text.strip(),
|
|
192
|
+
line_number=line_number,
|
|
193
|
+
absolute_offset=absolute_offset,
|
|
194
|
+
submatches=submatches,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if len(matches) >= max_matches:
|
|
199
|
+
break
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
return matches
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _find_files_with_ripgrep(
|
|
207
|
+
query: str,
|
|
208
|
+
base_path: str,
|
|
209
|
+
max_results: int = 100,
|
|
210
|
+
) -> list[str]:
|
|
211
|
+
"""Find files using ripgrep --files for better performance.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
query: Glob pattern to match file names.
|
|
215
|
+
base_path: Directory to search in.
|
|
216
|
+
max_results: Maximum number of results to return.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of relative file paths.
|
|
220
|
+
"""
|
|
221
|
+
# Build ripgrep command to list files matching glob
|
|
222
|
+
cmd = ["rg", "--files"]
|
|
223
|
+
|
|
224
|
+
# Add exclude patterns for SKIP_DIRS
|
|
225
|
+
for skip_dir in SKIP_DIRS:
|
|
226
|
+
cmd.extend(["--glob", f"!{skip_dir}/"])
|
|
227
|
+
|
|
228
|
+
# Add the file name pattern as a glob
|
|
229
|
+
# rg --files --glob supports matching anywhere in the path
|
|
230
|
+
# If query doesn't contain glob chars, wrap it with * for substring matching
|
|
231
|
+
glob_chars = {"*", "?", "[", "]"}
|
|
232
|
+
if not any(c in query for c in glob_chars):
|
|
233
|
+
query = f"*{query}*"
|
|
234
|
+
# Use **/ prefix to match the filename in any directory
|
|
235
|
+
cmd.extend(["--glob", f"**/{query}"])
|
|
236
|
+
cmd.append(base_path)
|
|
237
|
+
|
|
238
|
+
# Run ripgrep asynchronously
|
|
239
|
+
proc = await asyncio.create_subprocess_exec(
|
|
240
|
+
*cmd,
|
|
241
|
+
stdout=asyncio.subprocess.PIPE,
|
|
242
|
+
stderr=asyncio.subprocess.PIPE,
|
|
243
|
+
)
|
|
244
|
+
stdout, _ = await proc.communicate()
|
|
245
|
+
|
|
246
|
+
results: list[str] = []
|
|
247
|
+
base_path_prefix = base_path.rstrip("/") + "/"
|
|
248
|
+
|
|
249
|
+
for line in stdout.decode("utf-8", errors="replace").splitlines():
|
|
250
|
+
if not line.strip():
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Convert to relative path
|
|
254
|
+
rel_path = line[len(base_path_prefix) :] if line.startswith(base_path_prefix) else line
|
|
255
|
+
|
|
256
|
+
results.append(rel_path)
|
|
257
|
+
if len(results) >= max_results:
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
return sorted(results)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@router.get("/file")
|
|
264
|
+
async def list_files(state: StateDep, path: str = Query(default="")) -> list[FileNode]:
|
|
265
|
+
"""List files in a directory."""
|
|
266
|
+
working_path = Path(state.working_dir)
|
|
267
|
+
|
|
268
|
+
# Validate path if provided (empty path means root, which is always valid)
|
|
269
|
+
target_p = _validate_path(working_path, path) if path else working_path.resolve()
|
|
270
|
+
|
|
271
|
+
fs_info = _get_fs(state)
|
|
272
|
+
|
|
273
|
+
if fs_info is not None:
|
|
274
|
+
fs, _base_path = fs_info
|
|
275
|
+
# Use fsspec filesystem with validated path
|
|
276
|
+
target = str(target_p)
|
|
277
|
+
try:
|
|
278
|
+
if not await fs._isdir(target):
|
|
279
|
+
raise HTTPException(status_code=404, detail="Directory not found")
|
|
280
|
+
|
|
281
|
+
entries = await fs._ls(target, detail=True)
|
|
282
|
+
nodes = []
|
|
283
|
+
resolved_root = working_path.resolve()
|
|
284
|
+
for entry in entries:
|
|
285
|
+
full_name = entry.get("name", "")
|
|
286
|
+
name = full_name.split("/")[-1]
|
|
287
|
+
if not name:
|
|
288
|
+
continue
|
|
289
|
+
# Skip blocked files in directory listings
|
|
290
|
+
if name in BLOCKED_FILES:
|
|
291
|
+
continue
|
|
292
|
+
node_type = "directory" if entry.get("type") == "directory" else "file"
|
|
293
|
+
size = entry.get("size") if node_type == "file" else None
|
|
294
|
+
# Build relative path from resolved root
|
|
295
|
+
entry_path = Path(full_name)
|
|
296
|
+
try:
|
|
297
|
+
rel_path = str(entry_path.relative_to(resolved_root))
|
|
298
|
+
except ValueError:
|
|
299
|
+
rel_path = name
|
|
300
|
+
nodes.append(FileNode(name=name, path=rel_path or name, type=node_type, size=size))
|
|
301
|
+
return sorted(nodes, key=lambda n: (n.type != "directory", n.name.lower()))
|
|
302
|
+
except FileNotFoundError as err:
|
|
303
|
+
raise HTTPException(status_code=404, detail="Directory not found") from err
|
|
304
|
+
else:
|
|
305
|
+
# Fallback to local Path operations
|
|
306
|
+
if not target_p.is_dir():
|
|
307
|
+
raise HTTPException(status_code=404, detail="Directory not found")
|
|
308
|
+
|
|
309
|
+
nodes = []
|
|
310
|
+
resolved_root = working_path.resolve()
|
|
311
|
+
for entry in target_p.iterdir():
|
|
312
|
+
# Skip blocked files in directory listings
|
|
313
|
+
if entry.name in BLOCKED_FILES:
|
|
314
|
+
continue
|
|
315
|
+
node_type = "directory" if entry.is_dir() else "file"
|
|
316
|
+
size = entry.stat().st_size if entry.is_file() else None
|
|
317
|
+
rel_path = str(entry.relative_to(resolved_root))
|
|
318
|
+
nodes.append(FileNode(name=entry.name, path=rel_path, type=node_type, size=size))
|
|
319
|
+
|
|
320
|
+
return sorted(nodes, key=lambda n: (n.type != "directory", n.name.lower()))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@router.get("/file/content")
|
|
324
|
+
async def read_file(state: StateDep, path: str = Query()) -> FileContent:
|
|
325
|
+
"""Read a file's content."""
|
|
326
|
+
working_path = Path(state.working_dir)
|
|
327
|
+
|
|
328
|
+
# Validate path - this checks for traversal, symlink escapes, and blocked files
|
|
329
|
+
target = _validate_path(working_path, path)
|
|
330
|
+
|
|
331
|
+
fs_info = _get_fs(state)
|
|
332
|
+
|
|
333
|
+
if fs_info is not None:
|
|
334
|
+
fs, _base_path = fs_info
|
|
335
|
+
# Use fsspec filesystem with validated path
|
|
336
|
+
full_path = str(target)
|
|
337
|
+
try:
|
|
338
|
+
if not await fs._isfile(full_path):
|
|
339
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
340
|
+
content = await fs._cat_file(full_path)
|
|
341
|
+
if isinstance(content, bytes):
|
|
342
|
+
content = content.decode("utf-8")
|
|
343
|
+
return FileContent(path=path, content=content)
|
|
344
|
+
except FileNotFoundError as err:
|
|
345
|
+
raise HTTPException(status_code=404, detail="File not found") from err
|
|
346
|
+
except UnicodeDecodeError as err:
|
|
347
|
+
raise HTTPException(status_code=400, detail="Cannot read binary file") from err
|
|
348
|
+
else:
|
|
349
|
+
# Fallback to local Path operations (target already validated)
|
|
350
|
+
if not target.is_file():
|
|
351
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
content = target.read_text(encoding="utf-8")
|
|
355
|
+
return FileContent(path=path, content=content)
|
|
356
|
+
except UnicodeDecodeError as err:
|
|
357
|
+
raise HTTPException(status_code=400, detail="Cannot read binary file") from err
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@router.get("/file/status")
|
|
361
|
+
async def get_file_status(state: StateDep) -> list[dict[str, Any]]:
|
|
362
|
+
"""Get status of tracked files.
|
|
363
|
+
|
|
364
|
+
Returns empty list - file tracking not yet implemented.
|
|
365
|
+
"""
|
|
366
|
+
_ = state
|
|
367
|
+
return []
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@router.get("/find")
|
|
371
|
+
async def find_text(state: StateDep, pattern: str = Query()) -> list[FindMatch]: # noqa: PLR0915
|
|
372
|
+
"""Search for text pattern in files using regex."""
|
|
373
|
+
# Validate regex pattern
|
|
374
|
+
try:
|
|
375
|
+
re.compile(pattern)
|
|
376
|
+
except re.error as e:
|
|
377
|
+
raise HTTPException(status_code=400, detail=f"Invalid regex: {e}") from e
|
|
378
|
+
|
|
379
|
+
max_matches = 100
|
|
380
|
+
fs_info = _get_fs(state)
|
|
381
|
+
|
|
382
|
+
# Fast path: use ripgrep for local filesystems
|
|
383
|
+
if fs_info is not None:
|
|
384
|
+
fs, base_path = fs_info
|
|
385
|
+
if _is_local_fs(fs) and _has_ripgrep():
|
|
386
|
+
return await _search_with_ripgrep(pattern, base_path, max_matches)
|
|
387
|
+
|
|
388
|
+
# Fallback: use ripgrep directly if no fs but ripgrep available
|
|
389
|
+
if fs_info is None and _has_ripgrep():
|
|
390
|
+
return await _search_with_ripgrep(pattern, state.working_dir, max_matches)
|
|
391
|
+
|
|
392
|
+
# Slow path: manual file iteration
|
|
393
|
+
matches: list[FindMatch] = []
|
|
394
|
+
regex = re.compile(pattern)
|
|
395
|
+
|
|
396
|
+
if fs_info is not None:
|
|
397
|
+
fs, base_path = fs_info
|
|
398
|
+
|
|
399
|
+
# Use fsspec filesystem with walk
|
|
400
|
+
async def search_fs() -> None:
|
|
401
|
+
try:
|
|
402
|
+
# Use find to get all files recursively (limit depth to avoid scanning huge trees)
|
|
403
|
+
all_files = await fs._find(base_path, maxdepth=10, withdirs=False)
|
|
404
|
+
for file_path in all_files:
|
|
405
|
+
if len(matches) >= max_matches:
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
# Skip directories we don't want to search
|
|
409
|
+
parts = file_path.split("/")
|
|
410
|
+
if any(part in SKIP_DIRS for part in parts):
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
# Get relative path
|
|
414
|
+
if file_path.startswith(base_path):
|
|
415
|
+
rel_path = file_path[len(base_path) :].lstrip("/")
|
|
416
|
+
else:
|
|
417
|
+
rel_path = file_path
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
content = await fs._cat_file(file_path)
|
|
421
|
+
if isinstance(content, bytes):
|
|
422
|
+
content = content.decode("utf-8")
|
|
423
|
+
|
|
424
|
+
for line_num, line in enumerate(content.splitlines(), 1):
|
|
425
|
+
for match in regex.finditer(line):
|
|
426
|
+
submatches = [
|
|
427
|
+
SubmatchInfo.create(match.group(), match.start(), match.end())
|
|
428
|
+
]
|
|
429
|
+
matches.append(
|
|
430
|
+
FindMatch.create(
|
|
431
|
+
path=rel_path,
|
|
432
|
+
lines=line.strip(),
|
|
433
|
+
line_number=line_num,
|
|
434
|
+
absolute_offset=match.start(),
|
|
435
|
+
submatches=submatches,
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
if len(matches) >= max_matches:
|
|
439
|
+
return
|
|
440
|
+
except (UnicodeDecodeError, PermissionError, OSError):
|
|
441
|
+
continue
|
|
442
|
+
except Exception: # noqa: BLE001
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
await search_fs()
|
|
446
|
+
else:
|
|
447
|
+
# Fallback to local Path operations
|
|
448
|
+
working_path = Path(state.working_dir)
|
|
449
|
+
|
|
450
|
+
def search_dir(dir_path: Path) -> None:
|
|
451
|
+
if len(matches) >= max_matches:
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
for entry in dir_path.iterdir():
|
|
455
|
+
if len(matches) >= max_matches:
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
if entry.is_dir():
|
|
459
|
+
if entry.name not in SKIP_DIRS:
|
|
460
|
+
search_dir(entry)
|
|
461
|
+
elif entry.is_file():
|
|
462
|
+
try:
|
|
463
|
+
content = entry.read_text(encoding="utf-8")
|
|
464
|
+
for line_num, line in enumerate(content.splitlines(), 1):
|
|
465
|
+
for match in regex.finditer(line):
|
|
466
|
+
rel_path = str(entry.relative_to(working_path))
|
|
467
|
+
submatches = [
|
|
468
|
+
SubmatchInfo.create(match.group(), match.start(), match.end())
|
|
469
|
+
]
|
|
470
|
+
matches.append(
|
|
471
|
+
FindMatch.create(
|
|
472
|
+
path=rel_path,
|
|
473
|
+
lines=line.strip(),
|
|
474
|
+
line_number=line_num,
|
|
475
|
+
absolute_offset=match.start(),
|
|
476
|
+
submatches=submatches,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
if len(matches) >= max_matches:
|
|
480
|
+
return
|
|
481
|
+
except (UnicodeDecodeError, PermissionError, OSError):
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
search_dir(working_path)
|
|
485
|
+
|
|
486
|
+
return matches
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@router.get("/find/file")
|
|
490
|
+
async def find_files(
|
|
491
|
+
state: StateDep,
|
|
492
|
+
query: str = Query(),
|
|
493
|
+
dirs: str = Query(default="false"),
|
|
494
|
+
) -> list[str]:
|
|
495
|
+
"""Find files by name pattern (glob-style matching)."""
|
|
496
|
+
include_dirs = dirs.lower() == "true"
|
|
497
|
+
max_results = 100
|
|
498
|
+
fs_info = _get_fs(state)
|
|
499
|
+
|
|
500
|
+
# Fast path: use ripgrep for local filesystems (files only, not dirs)
|
|
501
|
+
if not include_dirs and _has_ripgrep():
|
|
502
|
+
if fs_info is not None:
|
|
503
|
+
fs, base_path = fs_info
|
|
504
|
+
if _is_local_fs(fs):
|
|
505
|
+
return await _find_files_with_ripgrep(query, base_path, max_results)
|
|
506
|
+
else:
|
|
507
|
+
return await _find_files_with_ripgrep(query, state.working_dir, max_results)
|
|
508
|
+
|
|
509
|
+
# Slow path: manual file iteration
|
|
510
|
+
results: list[str] = []
|
|
511
|
+
|
|
512
|
+
if fs_info is not None:
|
|
513
|
+
fs, base_path = fs_info
|
|
514
|
+
# Use fsspec filesystem
|
|
515
|
+
try:
|
|
516
|
+
# Get all entries recursively (limit depth to avoid scanning huge trees)
|
|
517
|
+
all_entries = await fs._find(base_path, maxdepth=10, withdirs=include_dirs)
|
|
518
|
+
for entry_path in all_entries:
|
|
519
|
+
if len(results) >= max_results:
|
|
520
|
+
break
|
|
521
|
+
|
|
522
|
+
# Skip directories we don't want to search
|
|
523
|
+
parts = entry_path.split("/")
|
|
524
|
+
if any(part in SKIP_DIRS for part in parts):
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
name = parts[-1] if parts else entry_path
|
|
528
|
+
if fnmatch.fnmatch(name, query):
|
|
529
|
+
# Get relative path
|
|
530
|
+
if entry_path.startswith(base_path):
|
|
531
|
+
rel_path = entry_path[len(base_path) :].lstrip("/")
|
|
532
|
+
else:
|
|
533
|
+
rel_path = entry_path
|
|
534
|
+
results.append(rel_path)
|
|
535
|
+
except Exception: # noqa: BLE001
|
|
536
|
+
pass
|
|
537
|
+
else:
|
|
538
|
+
# Fallback to local Path operations
|
|
539
|
+
working_path = Path(state.working_dir)
|
|
540
|
+
|
|
541
|
+
def search_dir(dir_path: Path) -> None:
|
|
542
|
+
if len(results) >= max_results:
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
for entry in dir_path.iterdir():
|
|
546
|
+
if len(results) >= max_results:
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
if entry.is_dir():
|
|
550
|
+
if entry.name not in SKIP_DIRS:
|
|
551
|
+
if include_dirs and fnmatch.fnmatch(entry.name, query):
|
|
552
|
+
results.append(str(entry.relative_to(working_path)))
|
|
553
|
+
search_dir(entry)
|
|
554
|
+
elif entry.is_file() and fnmatch.fnmatch(entry.name, query):
|
|
555
|
+
results.append(str(entry.relative_to(working_path)))
|
|
556
|
+
|
|
557
|
+
search_dir(working_path)
|
|
558
|
+
|
|
559
|
+
return sorted(results)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@router.get("/find/symbol")
|
|
563
|
+
async def find_symbols(state: StateDep, query: str = Query()) -> list[Symbol]:
|
|
564
|
+
"""Find workspace symbols.
|
|
565
|
+
|
|
566
|
+
Returns empty list - LSP symbol search not yet implemented.
|
|
567
|
+
"""
|
|
568
|
+
_ = state
|
|
569
|
+
_ = query
|
|
570
|
+
# TODO: Integrate with LSP or implement basic symbol extraction
|
|
571
|
+
return []
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Global routes (health, events)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter
|
|
10
|
+
from sse_starlette.sse import EventSourceResponse
|
|
11
|
+
|
|
12
|
+
from agentpool_server.opencode_server.dependencies import StateDep
|
|
13
|
+
from agentpool_server.opencode_server.models import ( # noqa: TC001
|
|
14
|
+
Event,
|
|
15
|
+
HealthResponse,
|
|
16
|
+
ServerConnectedEvent,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import AsyncGenerator
|
|
22
|
+
|
|
23
|
+
from agentpool_server.opencode_server.state import ServerState
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
router = APIRouter(tags=["global"])
|
|
28
|
+
|
|
29
|
+
VERSION = "0.1.0"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/global/health")
|
|
33
|
+
async def get_health() -> HealthResponse:
|
|
34
|
+
"""Get server health status."""
|
|
35
|
+
return HealthResponse(healthy=True, version=VERSION)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _serialize_event(event: Event, wrap_payload: bool = False) -> str:
|
|
39
|
+
"""Serialize event, optionally wrapping in payload structure."""
|
|
40
|
+
import json
|
|
41
|
+
|
|
42
|
+
event_data = event.model_dump(by_alias=True, exclude_none=True)
|
|
43
|
+
if wrap_payload:
|
|
44
|
+
return json.dumps({"payload": event_data})
|
|
45
|
+
return json.dumps(event_data)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _event_generator(
|
|
49
|
+
state: ServerState, *, wrap_payload: bool = False
|
|
50
|
+
) -> AsyncGenerator[dict[str, Any]]:
|
|
51
|
+
"""Generate SSE events."""
|
|
52
|
+
queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
53
|
+
state.event_subscribers.append(queue)
|
|
54
|
+
subscriber_count = len(state.event_subscribers)
|
|
55
|
+
logger.info("SSE: New client connected (total subscribers: %s)", subscriber_count)
|
|
56
|
+
|
|
57
|
+
# Trigger first subscriber callback if this is the first connection
|
|
58
|
+
if (
|
|
59
|
+
subscriber_count == 1
|
|
60
|
+
and not state._first_subscriber_triggered
|
|
61
|
+
and state.on_first_subscriber is not None
|
|
62
|
+
):
|
|
63
|
+
state._first_subscriber_triggered = True
|
|
64
|
+
state.create_background_task(state.on_first_subscriber(), name="on_first_subscriber")
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# Send initial connected event
|
|
68
|
+
connected = ServerConnectedEvent()
|
|
69
|
+
data = _serialize_event(connected, wrap_payload=wrap_payload)
|
|
70
|
+
logger.info("SSE: Sending connected event: %s", data)
|
|
71
|
+
yield {"data": data}
|
|
72
|
+
# Stream events
|
|
73
|
+
while True:
|
|
74
|
+
event = await queue.get()
|
|
75
|
+
data = _serialize_event(event, wrap_payload=wrap_payload)
|
|
76
|
+
logger.info("SSE: Sending event: %s", event.type)
|
|
77
|
+
yield {"data": data}
|
|
78
|
+
finally:
|
|
79
|
+
state.event_subscribers.remove(queue)
|
|
80
|
+
logger.info(
|
|
81
|
+
"SSE: Client disconnected (remaining subscribers: %s)", len(state.event_subscribers)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.get("/global/event")
|
|
86
|
+
async def get_global_events(state: StateDep) -> EventSourceResponse:
|
|
87
|
+
"""Get global events as SSE stream (uses payload wrapper)."""
|
|
88
|
+
return EventSourceResponse(_event_generator(state, wrap_payload=True), sep="\n")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.get("/event")
|
|
92
|
+
async def get_events(state: StateDep) -> EventSourceResponse:
|
|
93
|
+
"""Get events as SSE stream (no payload wrapper)."""
|
|
94
|
+
return EventSourceResponse(_event_generator(state, wrap_payload=False), sep="\n")
|