crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,1496 @@
|
|
|
1
|
+
"""Session management routes — /session/*."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
|
|
14
|
+
from crabcode_gateway.schemas import (
|
|
15
|
+
ArchiveSessionRequest,
|
|
16
|
+
ClearSessionRequest,
|
|
17
|
+
CompactRequest,
|
|
18
|
+
ExportSessionRequest,
|
|
19
|
+
ForkSessionRequest,
|
|
20
|
+
InterruptRequest,
|
|
21
|
+
NewSessionRequest,
|
|
22
|
+
PruneSessionsRequest,
|
|
23
|
+
ResumeSessionRequest,
|
|
24
|
+
SearchSessionsRequest,
|
|
25
|
+
SendMessageRequest,
|
|
26
|
+
SessionInfo,
|
|
27
|
+
SessionRuntimeStatus,
|
|
28
|
+
SearchIndexStatus,
|
|
29
|
+
)
|
|
30
|
+
from crabcode_gateway.event_bus import EventBus
|
|
31
|
+
from crabcode_gateway.turn_details import with_turn_details
|
|
32
|
+
from crabcode_gateway.session_registry import get_session_load_lock, get_session_lock
|
|
33
|
+
from crabcode_gateway.task_registry import (
|
|
34
|
+
cancel_operation_task,
|
|
35
|
+
mark_session_closing,
|
|
36
|
+
operation_is_registered,
|
|
37
|
+
release_operation_claim,
|
|
38
|
+
run_session_operation,
|
|
39
|
+
SessionOperationRejected,
|
|
40
|
+
shielded_cleanup_session,
|
|
41
|
+
track_task,
|
|
42
|
+
unmark_session_closing,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
router = APIRouter(prefix="/session", tags=["session"])
|
|
46
|
+
|
|
47
|
+
_SESSION_OVERRIDE_FIELDS = (
|
|
48
|
+
"model",
|
|
49
|
+
"provider",
|
|
50
|
+
"base_url",
|
|
51
|
+
"api_format",
|
|
52
|
+
"model_profile",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _session_time(value: Any) -> str:
|
|
57
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
58
|
+
try:
|
|
59
|
+
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
|
60
|
+
except (OverflowError, OSError, ValueError):
|
|
61
|
+
return str(value)
|
|
62
|
+
return str(value or "")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _session_info_from_row(row: dict[str, Any]) -> SessionInfo:
|
|
66
|
+
session_id = str(row.get("session_id") or row.get("id") or "")
|
|
67
|
+
return SessionInfo(
|
|
68
|
+
session_id=session_id,
|
|
69
|
+
message_count=int(row.get("message_count") or 0),
|
|
70
|
+
model=str(row.get("model") or ""),
|
|
71
|
+
provider=str(row.get("provider") or ""),
|
|
72
|
+
created_at=_session_time(
|
|
73
|
+
row.get("modified") or row.get("updated_at") or row.get("created_at")
|
|
74
|
+
),
|
|
75
|
+
title=str(row.get("title") or ""),
|
|
76
|
+
cwd=str(row.get("cwd") or ""),
|
|
77
|
+
tokens_used=int(row.get("tokens_used") or 0),
|
|
78
|
+
preview=str(row.get("preview") or row.get("first_user_message") or ""),
|
|
79
|
+
forked_from_session_id=(
|
|
80
|
+
str(row["forked_from_session_id"])
|
|
81
|
+
if row.get("forked_from_session_id") else None
|
|
82
|
+
),
|
|
83
|
+
forked_from_message_uuid=(
|
|
84
|
+
str(row["forked_from_message_uuid"])
|
|
85
|
+
if row.get("forked_from_message_uuid") else None
|
|
86
|
+
),
|
|
87
|
+
forked_from_title=(
|
|
88
|
+
str(row["forked_from_title"])
|
|
89
|
+
if row.get("forked_from_title") else None
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _validate_session_id(session_id: str) -> None:
|
|
95
|
+
"""Reject identifiers that cannot safely address a session transcript."""
|
|
96
|
+
from crabcode_core.session.storage import get_transcript_path
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
# Path construction performs the storage-boundary validation without
|
|
100
|
+
# touching disk. Keep malformed client input a 400 instead of letting
|
|
101
|
+
# it escape from CoreSession as an internal server error.
|
|
102
|
+
get_transcript_path(os.getcwd(), session_id)
|
|
103
|
+
except (TypeError, ValueError) as exc:
|
|
104
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _get_session(request: Request, session_id: str | None = None):
|
|
108
|
+
"""Retrieve a CoreSession from app state."""
|
|
109
|
+
sessions: dict = request.app.state.sessions
|
|
110
|
+
# ``None`` means the selector was omitted. An explicitly supplied empty
|
|
111
|
+
# or malformed id must never silently fall back to another conversation.
|
|
112
|
+
sid = (
|
|
113
|
+
request.app.state.default_session_id
|
|
114
|
+
if session_id is None
|
|
115
|
+
else session_id
|
|
116
|
+
)
|
|
117
|
+
if not sid or sid not in sessions:
|
|
118
|
+
return None
|
|
119
|
+
if sid in getattr(request.app.state, "closing_sessions", set()):
|
|
120
|
+
return None
|
|
121
|
+
return sessions[sid]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_session_selector(
|
|
125
|
+
selector: str,
|
|
126
|
+
cwd: str,
|
|
127
|
+
*,
|
|
128
|
+
loaded_sessions: dict[str, Any] | None = None,
|
|
129
|
+
) -> tuple[str, str] | None:
|
|
130
|
+
"""Resolve exact IDs, unique prefixes, or a local one-based index."""
|
|
131
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
132
|
+
from crabcode_core.session.storage import SessionStorage
|
|
133
|
+
|
|
134
|
+
value = str(selector or "").strip()
|
|
135
|
+
if not value:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
local_rows = SessionStorage.list_sessions(cwd)
|
|
140
|
+
except Exception:
|
|
141
|
+
local_rows = []
|
|
142
|
+
|
|
143
|
+
local_ids = [
|
|
144
|
+
str(row.get("session_id") or "")
|
|
145
|
+
for row in local_rows
|
|
146
|
+
if row.get("session_id")
|
|
147
|
+
]
|
|
148
|
+
if value in local_ids:
|
|
149
|
+
return value, cwd
|
|
150
|
+
local_matches = [session_id for session_id in local_ids if session_id.startswith(value)]
|
|
151
|
+
if len(local_matches) == 1:
|
|
152
|
+
return local_matches[0], cwd
|
|
153
|
+
if len(local_matches) > 1:
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
index = int(value) - 1
|
|
157
|
+
except ValueError:
|
|
158
|
+
index = -1
|
|
159
|
+
if 0 <= index < len(local_ids):
|
|
160
|
+
return local_ids[index], cwd
|
|
161
|
+
|
|
162
|
+
loaded = loaded_sessions or {}
|
|
163
|
+
if value in loaded:
|
|
164
|
+
session = loaded[value]
|
|
165
|
+
return value, str(getattr(session, "cwd", cwd))
|
|
166
|
+
loaded_matches = [session_id for session_id in loaded if session_id.startswith(value)]
|
|
167
|
+
if len(loaded_matches) == 1:
|
|
168
|
+
session_id = loaded_matches[0]
|
|
169
|
+
return session_id, str(getattr(loaded[session_id], "cwd", cwd))
|
|
170
|
+
if len(loaded_matches) > 1:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
store = SessionMetaStore()
|
|
174
|
+
try:
|
|
175
|
+
exact = store.get(value)
|
|
176
|
+
if exact and not exact.get("is_archived"):
|
|
177
|
+
return str(exact["id"]), str(exact.get("cwd") or cwd)
|
|
178
|
+
matches = store.find_active_by_prefix(value, limit=2)
|
|
179
|
+
finally:
|
|
180
|
+
store.close()
|
|
181
|
+
if len(matches) != 1:
|
|
182
|
+
return None
|
|
183
|
+
return str(matches[0]["id"]), str(matches[0].get("cwd") or cwd)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _attach_event_bus(session, event_bus: EventBus) -> None:
|
|
187
|
+
async def _publish(event) -> None:
|
|
188
|
+
await event_bus.publish_background(
|
|
189
|
+
session.session_id,
|
|
190
|
+
event,
|
|
191
|
+
source=session,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
session.set_background_event_sink(_publish)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _has_session_overrides(req: NewSessionRequest | ResumeSessionRequest) -> bool:
|
|
198
|
+
return any(getattr(req, field) is not None for field in _SESSION_OVERRIDE_FIELDS)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _build_session_settings(
|
|
202
|
+
req: NewSessionRequest | ResumeSessionRequest,
|
|
203
|
+
cwd: str,
|
|
204
|
+
):
|
|
205
|
+
"""Build caller overrides without shadowing the target project's config."""
|
|
206
|
+
from crabcode_core.config.manager import ConfigManager
|
|
207
|
+
from crabcode_core.types.config import CrabCodeSettings
|
|
208
|
+
|
|
209
|
+
explicit = (
|
|
210
|
+
CrabCodeSettings(
|
|
211
|
+
permissions={
|
|
212
|
+
"additional_directories": list(req.additional_directories),
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
if req.additional_directories
|
|
216
|
+
else CrabCodeSettings()
|
|
217
|
+
)
|
|
218
|
+
if req.model is not None:
|
|
219
|
+
explicit.api.model = req.model
|
|
220
|
+
if req.provider is not None:
|
|
221
|
+
explicit.api.provider = req.provider
|
|
222
|
+
if req.base_url is not None:
|
|
223
|
+
explicit.api.base_url = req.base_url
|
|
224
|
+
if req.api_format is not None:
|
|
225
|
+
explicit.api.format = req.api_format
|
|
226
|
+
if req.model_profile is not None:
|
|
227
|
+
configured = ConfigManager(cwd=cwd).load().models
|
|
228
|
+
if req.model_profile not in configured:
|
|
229
|
+
available = ", ".join(sorted(configured)) or "none"
|
|
230
|
+
raise HTTPException(
|
|
231
|
+
status_code=400,
|
|
232
|
+
detail=(
|
|
233
|
+
f"Unknown model profile '{req.model_profile}'. "
|
|
234
|
+
f"Available profiles: {available}"
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
explicit.default_model = req.model_profile
|
|
238
|
+
|
|
239
|
+
settings = CrabCodeSettings()
|
|
240
|
+
settings._crabcode_explicit_settings = explicit
|
|
241
|
+
return settings
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _session_model_provider(session) -> tuple[str, str]:
|
|
245
|
+
settings = getattr(session, "settings", None)
|
|
246
|
+
if settings is None:
|
|
247
|
+
return "", ""
|
|
248
|
+
api = settings.get_api_config() if hasattr(settings, "get_api_config") else None
|
|
249
|
+
return str(getattr(api, "model", None) or ""), str(
|
|
250
|
+
getattr(api, "provider", None) or ""
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _bind_computer_use(
|
|
255
|
+
session: Any,
|
|
256
|
+
state: Any,
|
|
257
|
+
host_id: str | None,
|
|
258
|
+
enabled: bool | None = None,
|
|
259
|
+
mode: str | None = None,
|
|
260
|
+
target_scope: str | None = None,
|
|
261
|
+
delivery_policy: str | None = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Bind a session to a Desktop host before tools are initialized."""
|
|
264
|
+
if enabled is not None:
|
|
265
|
+
session.computer_use_enabled = enabled
|
|
266
|
+
session._computer_use_requested_enabled = enabled
|
|
267
|
+
if target_scope is not None:
|
|
268
|
+
mode = "foreground_desktop" if target_scope == "desktop" else "background_app"
|
|
269
|
+
if mode is not None:
|
|
270
|
+
session._computer_use_mode_override = mode
|
|
271
|
+
session.computer_use_mode = mode
|
|
272
|
+
session.computer_use_target_scope = "desktop" if mode == "foreground_desktop" else "app_window"
|
|
273
|
+
if delivery_policy is not None:
|
|
274
|
+
session._computer_use_delivery_policy_override = delivery_policy
|
|
275
|
+
session.computer_use_delivery_policy = delivery_policy
|
|
276
|
+
if host_id is None:
|
|
277
|
+
return
|
|
278
|
+
session.computer_use_backend = state.computer_use_broker
|
|
279
|
+
session.computer_use_host_id = host_id
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _apply_new_session_controls(session: Any, req: NewSessionRequest) -> None:
|
|
283
|
+
"""Apply client composer controls before the new session is announced."""
|
|
284
|
+
if req.reasoning_effort is not None:
|
|
285
|
+
session.set_reasoning_effort(req.reasoning_effort)
|
|
286
|
+
if req.ultra_mode is not None:
|
|
287
|
+
session.set_ultra_mode(req.ultra_mode)
|
|
288
|
+
if req.permission_mode is not None:
|
|
289
|
+
session.set_client_permission_mode(req.permission_mode)
|
|
290
|
+
if req.mode is not None:
|
|
291
|
+
session.switch_mode(req.mode)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@router.post("/new", response_model=SessionInfo)
|
|
295
|
+
async def new_session(req: NewSessionRequest, request: Request) -> SessionInfo:
|
|
296
|
+
"""Create a new CrabCode session."""
|
|
297
|
+
import os
|
|
298
|
+
from crabcode_core.session import CoreSession
|
|
299
|
+
|
|
300
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
301
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
302
|
+
|
|
303
|
+
cwd = req.cwd or os.getcwd()
|
|
304
|
+
|
|
305
|
+
settings = _build_session_settings(req, cwd)
|
|
306
|
+
session = None
|
|
307
|
+
registered_session_id: str | None = None
|
|
308
|
+
cleanup_owns_registry = False
|
|
309
|
+
try:
|
|
310
|
+
session = CoreSession(cwd=cwd, settings=settings)
|
|
311
|
+
_bind_computer_use(
|
|
312
|
+
session,
|
|
313
|
+
request.app.state,
|
|
314
|
+
req.computer_use_host_id,
|
|
315
|
+
req.computer_use_enabled,
|
|
316
|
+
req.computer_use_mode,
|
|
317
|
+
target_scope=req.computer_use_target_scope,
|
|
318
|
+
delivery_policy=req.computer_use_delivery_policy,
|
|
319
|
+
)
|
|
320
|
+
_attach_event_bus(session, request.app.state.event_bus)
|
|
321
|
+
await session.initialize()
|
|
322
|
+
session.new_session()
|
|
323
|
+
_apply_new_session_controls(session, req)
|
|
324
|
+
|
|
325
|
+
async with get_session_lock(request.app.state):
|
|
326
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
327
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
328
|
+
sessions: dict = request.app.state.sessions
|
|
329
|
+
session.reload_computer_use_settings()
|
|
330
|
+
request.app.state.event_bus.register_session(session.session_id, session)
|
|
331
|
+
sessions[session.session_id] = session
|
|
332
|
+
request.app.state.default_session_id = session.session_id
|
|
333
|
+
registered_session_id = session.session_id
|
|
334
|
+
except BaseException:
|
|
335
|
+
if registered_session_id is not None:
|
|
336
|
+
async with get_session_lock(request.app.state):
|
|
337
|
+
sessions = request.app.state.sessions
|
|
338
|
+
if sessions.get(registered_session_id) is session:
|
|
339
|
+
cleanup_owns_registry = True
|
|
340
|
+
mark_session_closing(request.app.state, registered_session_id)
|
|
341
|
+
close_session_events = getattr(request.app.state.event_bus, "close_session", None)
|
|
342
|
+
if callable(close_session_events):
|
|
343
|
+
close_session_events(registered_session_id, session)
|
|
344
|
+
sessions.pop(registered_session_id, None)
|
|
345
|
+
if request.app.state.default_session_id == registered_session_id:
|
|
346
|
+
request.app.state.default_session_id = next(iter(sessions), None)
|
|
347
|
+
if session is not None:
|
|
348
|
+
try:
|
|
349
|
+
await shielded_cleanup_session(
|
|
350
|
+
request.app.state,
|
|
351
|
+
getattr(session, "session_id", "") or f"unregistered:{id(session)}",
|
|
352
|
+
session,
|
|
353
|
+
owns_registry=cleanup_owns_registry,
|
|
354
|
+
)
|
|
355
|
+
except Exception:
|
|
356
|
+
pass
|
|
357
|
+
raise
|
|
358
|
+
|
|
359
|
+
model, provider = _session_model_provider(session)
|
|
360
|
+
meta = getattr(getattr(session, "_session_storage", None), "meta", {}) or {}
|
|
361
|
+
return SessionInfo(
|
|
362
|
+
session_id=session.session_id,
|
|
363
|
+
message_count=0,
|
|
364
|
+
model=model,
|
|
365
|
+
provider=provider,
|
|
366
|
+
title=str(meta.get("title") or ""),
|
|
367
|
+
cwd=str(getattr(session, "cwd", "") or ""),
|
|
368
|
+
forked_from_session_id=meta.get("forked_from_session_id"),
|
|
369
|
+
forked_from_message_uuid=meta.get("forked_from_message_uuid"),
|
|
370
|
+
forked_from_title=meta.get("forked_from_title"),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@router.post("/resume", response_model=SessionInfo)
|
|
375
|
+
async def resume_session(req: ResumeSessionRequest, request: Request) -> SessionInfo:
|
|
376
|
+
"""Resume a session selected by ID, unique prefix, or local index."""
|
|
377
|
+
import os
|
|
378
|
+
from crabcode_core.session import CoreSession
|
|
379
|
+
|
|
380
|
+
if not req.session_id:
|
|
381
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
382
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
383
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
384
|
+
|
|
385
|
+
async with get_session_lock(request.app.state):
|
|
386
|
+
loaded = dict(request.app.state.sessions)
|
|
387
|
+
default_id = request.app.state.default_session_id
|
|
388
|
+
selector_cwd = os.getcwd()
|
|
389
|
+
if default_id and default_id in loaded:
|
|
390
|
+
selector_cwd = str(getattr(loaded[default_id], "cwd", selector_cwd))
|
|
391
|
+
try:
|
|
392
|
+
resolved = _resolve_session_selector(
|
|
393
|
+
req.session_id,
|
|
394
|
+
selector_cwd,
|
|
395
|
+
loaded_sessions=loaded,
|
|
396
|
+
)
|
|
397
|
+
except Exception as exc:
|
|
398
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
399
|
+
if resolved is None:
|
|
400
|
+
raise HTTPException(
|
|
401
|
+
status_code=404,
|
|
402
|
+
detail="Session not found or selector is ambiguous",
|
|
403
|
+
)
|
|
404
|
+
session_id, resolved_cwd = resolved
|
|
405
|
+
_validate_session_id(session_id)
|
|
406
|
+
|
|
407
|
+
# Fast path for an already loaded session. The registry lock is held only
|
|
408
|
+
# for in-memory state; provider/LSP initialization happens below without
|
|
409
|
+
# blocking archive/send/stop.
|
|
410
|
+
async with get_session_lock(request.app.state):
|
|
411
|
+
sessions: dict = request.app.state.sessions
|
|
412
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
413
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
414
|
+
if session_id in getattr(request.app.state, "closing_sessions", set()):
|
|
415
|
+
raise HTTPException(status_code=503, detail="Session is shutting down")
|
|
416
|
+
session = sessions.get(session_id)
|
|
417
|
+
if session is not None:
|
|
418
|
+
if _has_session_overrides(req):
|
|
419
|
+
raise HTTPException(
|
|
420
|
+
status_code=409,
|
|
421
|
+
detail="Cannot apply model overrides to an already loaded session",
|
|
422
|
+
)
|
|
423
|
+
request.app.state.default_session_id = session.session_id
|
|
424
|
+
_bind_computer_use(
|
|
425
|
+
session,
|
|
426
|
+
request.app.state,
|
|
427
|
+
req.computer_use_host_id,
|
|
428
|
+
req.computer_use_enabled,
|
|
429
|
+
req.computer_use_mode,
|
|
430
|
+
target_scope=req.computer_use_target_scope,
|
|
431
|
+
delivery_policy=req.computer_use_delivery_policy,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if session is None:
|
|
435
|
+
# Serialize disk-backed loads so concurrent resume calls cannot create
|
|
436
|
+
# duplicate CoreSession instances for the same id. This lock is
|
|
437
|
+
# deliberately distinct from the short registry lock above.
|
|
438
|
+
async with get_session_load_lock(request.app.state):
|
|
439
|
+
async with get_session_lock(request.app.state):
|
|
440
|
+
sessions = request.app.state.sessions
|
|
441
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
442
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
443
|
+
if session_id in getattr(request.app.state, "closing_sessions", set()):
|
|
444
|
+
raise HTTPException(status_code=503, detail="Session is shutting down")
|
|
445
|
+
session = sessions.get(session_id)
|
|
446
|
+
if session is not None and _has_session_overrides(req):
|
|
447
|
+
raise HTTPException(
|
|
448
|
+
status_code=409,
|
|
449
|
+
detail="Cannot apply model overrides to an already loaded session",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if session is None:
|
|
453
|
+
candidate = CoreSession(
|
|
454
|
+
cwd=resolved_cwd,
|
|
455
|
+
settings=_build_session_settings(req, resolved_cwd),
|
|
456
|
+
)
|
|
457
|
+
_bind_computer_use(
|
|
458
|
+
candidate,
|
|
459
|
+
request.app.state,
|
|
460
|
+
req.computer_use_host_id,
|
|
461
|
+
req.computer_use_enabled,
|
|
462
|
+
req.computer_use_mode,
|
|
463
|
+
target_scope=req.computer_use_target_scope,
|
|
464
|
+
delivery_policy=req.computer_use_delivery_policy,
|
|
465
|
+
)
|
|
466
|
+
_attach_event_bus(candidate, request.app.state.event_bus)
|
|
467
|
+
try:
|
|
468
|
+
await candidate.initialize()
|
|
469
|
+
ok = await candidate.resume(session_id)
|
|
470
|
+
except BaseException:
|
|
471
|
+
try:
|
|
472
|
+
await shielded_cleanup_session(
|
|
473
|
+
request.app.state,
|
|
474
|
+
session_id,
|
|
475
|
+
candidate,
|
|
476
|
+
owns_registry=False,
|
|
477
|
+
)
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
raise
|
|
481
|
+
if not ok:
|
|
482
|
+
await shielded_cleanup_session(
|
|
483
|
+
request.app.state,
|
|
484
|
+
session_id,
|
|
485
|
+
candidate,
|
|
486
|
+
owns_registry=False,
|
|
487
|
+
)
|
|
488
|
+
raise HTTPException(
|
|
489
|
+
status_code=404,
|
|
490
|
+
detail=f"Session {session_id} not found",
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Reacquire the registry lock only for the atomic install. A
|
|
494
|
+
# concurrent archive/stop may have fenced the id while the
|
|
495
|
+
# candidate was loading; in that case close it and reject.
|
|
496
|
+
discard_candidate = False
|
|
497
|
+
reject_candidate = False
|
|
498
|
+
async with get_session_lock(request.app.state):
|
|
499
|
+
if (
|
|
500
|
+
getattr(request.app.state, "gateway_closing", False)
|
|
501
|
+
or session_id in getattr(request.app.state, "closing_sessions", set())
|
|
502
|
+
):
|
|
503
|
+
discard_candidate = True
|
|
504
|
+
reject_candidate = True
|
|
505
|
+
else:
|
|
506
|
+
existing = request.app.state.sessions.get(session_id)
|
|
507
|
+
if existing is None:
|
|
508
|
+
candidate.reload_computer_use_settings()
|
|
509
|
+
request.app.state.event_bus.register_session(
|
|
510
|
+
candidate.session_id,
|
|
511
|
+
candidate,
|
|
512
|
+
)
|
|
513
|
+
request.app.state.sessions[candidate.session_id] = candidate
|
|
514
|
+
session = candidate
|
|
515
|
+
else:
|
|
516
|
+
session = existing
|
|
517
|
+
# Another resume/new request won the install race;
|
|
518
|
+
# this fully initialized candidate is no longer
|
|
519
|
+
# owned by the registry and must be closed without
|
|
520
|
+
# fencing the winner's tasks.
|
|
521
|
+
discard_candidate = True
|
|
522
|
+
request.app.state.default_session_id = session.session_id
|
|
523
|
+
if discard_candidate:
|
|
524
|
+
try:
|
|
525
|
+
await shielded_cleanup_session(
|
|
526
|
+
request.app.state,
|
|
527
|
+
session_id,
|
|
528
|
+
candidate,
|
|
529
|
+
owns_registry=False,
|
|
530
|
+
)
|
|
531
|
+
except Exception:
|
|
532
|
+
pass
|
|
533
|
+
if reject_candidate:
|
|
534
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
535
|
+
|
|
536
|
+
else:
|
|
537
|
+
async with get_session_lock(request.app.state):
|
|
538
|
+
request.app.state.default_session_id = session.session_id
|
|
539
|
+
|
|
540
|
+
if session is not None:
|
|
541
|
+
_bind_computer_use(
|
|
542
|
+
session,
|
|
543
|
+
request.app.state,
|
|
544
|
+
req.computer_use_host_id,
|
|
545
|
+
req.computer_use_enabled,
|
|
546
|
+
req.computer_use_mode,
|
|
547
|
+
target_scope=req.computer_use_target_scope,
|
|
548
|
+
delivery_policy=req.computer_use_delivery_policy,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Capture the primitive count while the installed object is still fenced
|
|
552
|
+
# by the registry. Returning ``len(session.messages)`` after releasing the
|
|
553
|
+
# lock could observe a replacement/close or a concurrently appended turn.
|
|
554
|
+
async with get_session_lock(request.app.state):
|
|
555
|
+
if (
|
|
556
|
+
request.app.state.sessions.get(session.session_id) is not session
|
|
557
|
+
or session.session_id in getattr(request.app.state, "closing_sessions", set())
|
|
558
|
+
):
|
|
559
|
+
raise HTTPException(status_code=503, detail="Session is changing")
|
|
560
|
+
message_count = len(getattr(session, "messages", ()))
|
|
561
|
+
|
|
562
|
+
model, provider = _session_model_provider(session)
|
|
563
|
+
meta = getattr(getattr(session, "_session_storage", None), "meta", {}) or {}
|
|
564
|
+
return SessionInfo(
|
|
565
|
+
session_id=session.session_id,
|
|
566
|
+
message_count=message_count,
|
|
567
|
+
model=model,
|
|
568
|
+
provider=provider,
|
|
569
|
+
title=str(meta.get("title") or ""),
|
|
570
|
+
cwd=str(getattr(session, "cwd", "") or ""),
|
|
571
|
+
forked_from_session_id=meta.get("forked_from_session_id"),
|
|
572
|
+
forked_from_message_uuid=meta.get("forked_from_message_uuid"),
|
|
573
|
+
forked_from_title=meta.get("forked_from_title"),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@router.get("/messages")
|
|
578
|
+
async def session_messages(
|
|
579
|
+
request: Request,
|
|
580
|
+
session_id: str | None = None,
|
|
581
|
+
) -> list[dict[str, Any]]:
|
|
582
|
+
"""Return the active message projection for one session.
|
|
583
|
+
|
|
584
|
+
The selector is intentionally session-scoped: an omitted selector uses the
|
|
585
|
+
legacy default session, while an explicit unknown or empty selector is a
|
|
586
|
+
404 and never falls through to another conversation. Copy the list while
|
|
587
|
+
holding the registry lock so archive/resume cannot replace it halfway
|
|
588
|
+
through serialization.
|
|
589
|
+
"""
|
|
590
|
+
async with get_session_lock(request.app.state):
|
|
591
|
+
session = _get_session(request, session_id)
|
|
592
|
+
if session is None:
|
|
593
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
594
|
+
# Copy message objects, not only the list container. A query loop can
|
|
595
|
+
# update an assistant message in place while it streams; serializing
|
|
596
|
+
# the original objects after releasing the registry lock could return
|
|
597
|
+
# a mixed/partially-mutated response.
|
|
598
|
+
messages = []
|
|
599
|
+
for message in getattr(session, "messages", ()):
|
|
600
|
+
try:
|
|
601
|
+
copier = getattr(message, "model_copy", None)
|
|
602
|
+
messages.append(
|
|
603
|
+
copier(deep=True) if callable(copier) else deepcopy(message)
|
|
604
|
+
)
|
|
605
|
+
except Exception:
|
|
606
|
+
# Lightweight integrations may expose non-copyable message
|
|
607
|
+
# doubles. Keep the endpoint usable for those callers.
|
|
608
|
+
messages.append(message)
|
|
609
|
+
|
|
610
|
+
result: list[dict[str, Any]] = []
|
|
611
|
+
for message in messages:
|
|
612
|
+
try:
|
|
613
|
+
payload = message.model_dump(mode="json")
|
|
614
|
+
except (AttributeError, TypeError, ValueError):
|
|
615
|
+
# Keep the endpoint useful for lightweight CoreSession doubles used
|
|
616
|
+
# by integrations and tests.
|
|
617
|
+
payload = {
|
|
618
|
+
key: getattr(message, key)
|
|
619
|
+
for key in ("uuid", "parent_uuid", "role", "content", "timestamp")
|
|
620
|
+
if hasattr(message, key)
|
|
621
|
+
}
|
|
622
|
+
if not isinstance(payload, dict):
|
|
623
|
+
continue
|
|
624
|
+
role = payload.get("role")
|
|
625
|
+
if hasattr(role, "value"):
|
|
626
|
+
payload["role"] = role.value
|
|
627
|
+
result.append(payload)
|
|
628
|
+
return with_turn_details(result, session.session_id)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@router.post("/fork", response_model=SessionInfo)
|
|
632
|
+
async def fork_session(req: ForkSessionRequest, request: Request) -> SessionInfo:
|
|
633
|
+
"""Fork a durable session from a selected, or the latest, assistant reply."""
|
|
634
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
635
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
636
|
+
from crabcode_core.session.storage import SessionStorage
|
|
637
|
+
async with get_session_lock(request.app.state):
|
|
638
|
+
loaded = dict(request.app.state.sessions)
|
|
639
|
+
default_id = request.app.state.default_session_id
|
|
640
|
+
selector_cwd = os.getcwd()
|
|
641
|
+
if default_id and default_id in loaded:
|
|
642
|
+
selector_cwd = str(getattr(loaded[default_id], "cwd", selector_cwd))
|
|
643
|
+
resolved = _resolve_session_selector(
|
|
644
|
+
req.session_id,
|
|
645
|
+
selector_cwd,
|
|
646
|
+
loaded_sessions=loaded,
|
|
647
|
+
)
|
|
648
|
+
if resolved is None:
|
|
649
|
+
raise HTTPException(status_code=404, detail="Session not found or selector is ambiguous")
|
|
650
|
+
source_id, source_cwd = resolved
|
|
651
|
+
source = loaded.get(source_id)
|
|
652
|
+
if source is not None and (
|
|
653
|
+
bool(getattr(source, "_foreground_turn_active", False))
|
|
654
|
+
or bool(getattr(getattr(source, "_turn_lock", None), "locked", lambda: False)())
|
|
655
|
+
):
|
|
656
|
+
raise HTTPException(status_code=409, detail="Cannot fork a session while it is running")
|
|
657
|
+
try:
|
|
658
|
+
forked = SessionStorage.fork_from(
|
|
659
|
+
source_cwd,
|
|
660
|
+
source_id,
|
|
661
|
+
req.message_uuid,
|
|
662
|
+
title=req.title,
|
|
663
|
+
)
|
|
664
|
+
except ValueError as exc:
|
|
665
|
+
detail = str(exc)
|
|
666
|
+
status = 404 if "not found" in detail.lower() else 400
|
|
667
|
+
raise HTTPException(status_code=status, detail=detail) from exc
|
|
668
|
+
return _session_info_from_row({**forked.meta, "session_id": forked.session_id})
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@router.get("/list", response_model=list[SessionInfo])
|
|
672
|
+
async def list_sessions(
|
|
673
|
+
request: Request,
|
|
674
|
+
cwd: str | None = None,
|
|
675
|
+
) -> list[SessionInfo]:
|
|
676
|
+
"""List all persisted sessions for the current working directory.
|
|
677
|
+
|
|
678
|
+
Uses SessionStorage.list_sessions (disk-based) so sub-agent sessions
|
|
679
|
+
are never included — only top-level user conversations appear here.
|
|
680
|
+
"""
|
|
681
|
+
import os
|
|
682
|
+
from crabcode_core.session.storage import SessionStorage
|
|
683
|
+
|
|
684
|
+
# Preserve the existing default-session behavior when cwd is omitted.
|
|
685
|
+
selected_cwd = cwd
|
|
686
|
+
if selected_cwd is None:
|
|
687
|
+
async with get_session_lock(request.app.state):
|
|
688
|
+
sessions: dict = request.app.state.sessions
|
|
689
|
+
default_id = request.app.state.default_session_id
|
|
690
|
+
selected_cwd = os.getcwd()
|
|
691
|
+
if default_id and default_id in sessions:
|
|
692
|
+
selected_cwd = getattr(sessions[default_id], "cwd", selected_cwd)
|
|
693
|
+
else:
|
|
694
|
+
candidate = Path(selected_cwd).expanduser()
|
|
695
|
+
try:
|
|
696
|
+
candidate = candidate.resolve(strict=True)
|
|
697
|
+
except OSError as exc:
|
|
698
|
+
raise HTTPException(status_code=404, detail="Working directory not found") from exc
|
|
699
|
+
if not candidate.is_dir():
|
|
700
|
+
raise HTTPException(status_code=400, detail="cwd must be a directory")
|
|
701
|
+
selected_cwd = str(candidate)
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
stored = SessionStorage.list_sessions(selected_cwd)
|
|
705
|
+
except Exception:
|
|
706
|
+
stored = []
|
|
707
|
+
|
|
708
|
+
return [_session_info_from_row({**s, "cwd": selected_cwd}) for s in stored]
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@router.get("/resolve", response_model=SessionInfo)
|
|
712
|
+
async def resolve_session(selector: str, request: Request) -> SessionInfo:
|
|
713
|
+
"""Resolve a session ID, unique prefix, or local list index."""
|
|
714
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
715
|
+
from crabcode_core.session.storage import SessionStorage
|
|
716
|
+
|
|
717
|
+
async with get_session_lock(request.app.state):
|
|
718
|
+
loaded = dict(request.app.state.sessions)
|
|
719
|
+
default_id = request.app.state.default_session_id
|
|
720
|
+
cwd = os.getcwd()
|
|
721
|
+
if default_id and default_id in loaded:
|
|
722
|
+
cwd = str(getattr(loaded[default_id], "cwd", cwd))
|
|
723
|
+
try:
|
|
724
|
+
resolved = _resolve_session_selector(selector, cwd, loaded_sessions=loaded)
|
|
725
|
+
except Exception as exc:
|
|
726
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
727
|
+
if resolved is None:
|
|
728
|
+
raise HTTPException(status_code=404, detail="Session not found or selector is ambiguous")
|
|
729
|
+
session_id, resolved_cwd = resolved
|
|
730
|
+
|
|
731
|
+
session = loaded.get(session_id)
|
|
732
|
+
if session is not None:
|
|
733
|
+
current_name = getattr(session, "_current_model_name", None)
|
|
734
|
+
settings = getattr(session, "settings", None)
|
|
735
|
+
active_config = (
|
|
736
|
+
settings.get_api_config(current_name)
|
|
737
|
+
if settings is not None and hasattr(settings, "get_api_config")
|
|
738
|
+
else None
|
|
739
|
+
)
|
|
740
|
+
session_meta = getattr(getattr(session, "_session_storage", None), "meta", {}) or {}
|
|
741
|
+
return SessionInfo(
|
|
742
|
+
session_id=session_id,
|
|
743
|
+
message_count=len(getattr(session, "messages", ())),
|
|
744
|
+
model=str(getattr(active_config, "model", "") or ""),
|
|
745
|
+
provider=str(getattr(active_config, "provider", "") or ""),
|
|
746
|
+
cwd=resolved_cwd,
|
|
747
|
+
title=str(session_meta.get("title") or ""),
|
|
748
|
+
forked_from_session_id=session_meta.get("forked_from_session_id"),
|
|
749
|
+
forked_from_message_uuid=session_meta.get("forked_from_message_uuid"),
|
|
750
|
+
forked_from_title=session_meta.get("forked_from_title"),
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
store = SessionMetaStore()
|
|
754
|
+
try:
|
|
755
|
+
row = store.get(session_id)
|
|
756
|
+
finally:
|
|
757
|
+
store.close()
|
|
758
|
+
if row is not None:
|
|
759
|
+
return _session_info_from_row(row)
|
|
760
|
+
local = next(
|
|
761
|
+
(
|
|
762
|
+
item
|
|
763
|
+
for item in SessionStorage.list_sessions(resolved_cwd)
|
|
764
|
+
if item.get("session_id") == session_id
|
|
765
|
+
),
|
|
766
|
+
None,
|
|
767
|
+
)
|
|
768
|
+
if local is None:
|
|
769
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
770
|
+
return _session_info_from_row({**local, "cwd": resolved_cwd})
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
@router.post("/send")
|
|
774
|
+
async def send_message(req: SendMessageRequest, request: Request):
|
|
775
|
+
"""Send a message and stream back CoreEvents as SSE.
|
|
776
|
+
|
|
777
|
+
This is the primary interaction endpoint. It starts the query
|
|
778
|
+
loop and streams events via the event bus SSE channel.
|
|
779
|
+
"""
|
|
780
|
+
event_bus: EventBus = request.app.state.event_bus
|
|
781
|
+
|
|
782
|
+
# Fire-and-forget: run the query loop, publish events to the bus
|
|
783
|
+
import asyncio
|
|
784
|
+
|
|
785
|
+
async def _run():
|
|
786
|
+
try:
|
|
787
|
+
from crabcode_core.types.event import TurnCompleteEvent
|
|
788
|
+
|
|
789
|
+
images = [img.model_dump() for img in req.images] if req.images else None
|
|
790
|
+
pending_terminal: TurnCompleteEvent | None = None
|
|
791
|
+
async for event in session.send_message(req.text, max_turns=req.max_turns, images=images):
|
|
792
|
+
if isinstance(event, TurnCompleteEvent):
|
|
793
|
+
# A steering message can extend the same Core generator
|
|
794
|
+
# after query_loop produced a candidate terminal. Publish
|
|
795
|
+
# only the candidate that remains when the generator ends.
|
|
796
|
+
pending_terminal = event
|
|
797
|
+
continue
|
|
798
|
+
pending_terminal = None
|
|
799
|
+
await event_bus.publish(
|
|
800
|
+
session.session_id,
|
|
801
|
+
event,
|
|
802
|
+
source=session,
|
|
803
|
+
operation_id=operation_id,
|
|
804
|
+
operation_scope="foreground",
|
|
805
|
+
)
|
|
806
|
+
if pending_terminal is None:
|
|
807
|
+
# A transport adapter may finish without Core's explicit
|
|
808
|
+
# terminal event (for example after yielding a recoverable
|
|
809
|
+
# error). Synthesize one so HTTP/SSE and WebSocket clients
|
|
810
|
+
# share the same foreground lifecycle contract.
|
|
811
|
+
pending_terminal = TurnCompleteEvent(reason="error")
|
|
812
|
+
await event_bus.publish(
|
|
813
|
+
session.session_id,
|
|
814
|
+
pending_terminal,
|
|
815
|
+
source=session,
|
|
816
|
+
operation_id=operation_id,
|
|
817
|
+
operation_scope="foreground",
|
|
818
|
+
)
|
|
819
|
+
current = asyncio.current_task()
|
|
820
|
+
if current is not None:
|
|
821
|
+
setattr(current, "_crabcode_terminal_published", True)
|
|
822
|
+
except asyncio.CancelledError:
|
|
823
|
+
raise
|
|
824
|
+
except Exception as exc:
|
|
825
|
+
from crabcode_core.types.event import ErrorEvent
|
|
826
|
+
await event_bus.publish(
|
|
827
|
+
session.session_id,
|
|
828
|
+
ErrorEvent(message=str(exc), recoverable=False, error_type="internal"),
|
|
829
|
+
source=session,
|
|
830
|
+
operation_id=operation_id,
|
|
831
|
+
operation_scope="foreground",
|
|
832
|
+
)
|
|
833
|
+
from crabcode_core.types.event import TurnCompleteEvent
|
|
834
|
+
await event_bus.publish(
|
|
835
|
+
session.session_id,
|
|
836
|
+
TurnCompleteEvent(reason="error"),
|
|
837
|
+
source=session,
|
|
838
|
+
operation_id=operation_id,
|
|
839
|
+
operation_scope="foreground",
|
|
840
|
+
)
|
|
841
|
+
current = asyncio.current_task()
|
|
842
|
+
if current is not None:
|
|
843
|
+
setattr(current, "_crabcode_terminal_published", True)
|
|
844
|
+
|
|
845
|
+
# Resolve and register under the same app lock used by archive/stop. This
|
|
846
|
+
# closes the race where a request captured a session just before it was
|
|
847
|
+
# removed and then started a query after CoreSession.close().
|
|
848
|
+
async with get_session_lock(request.app.state):
|
|
849
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
850
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
851
|
+
session = _get_session(request, req.session_id)
|
|
852
|
+
if not session:
|
|
853
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
854
|
+
_bind_computer_use(
|
|
855
|
+
session, request.app.state, None, req.computer_use_enabled, req.computer_use_mode,
|
|
856
|
+
req.computer_use_target_scope, req.computer_use_delivery_policy,
|
|
857
|
+
)
|
|
858
|
+
operation_id = req.operation_id or uuid.uuid4().hex
|
|
859
|
+
if req.operation_id is None:
|
|
860
|
+
while operation_is_registered(
|
|
861
|
+
request.app.state,
|
|
862
|
+
session.session_id,
|
|
863
|
+
operation_id,
|
|
864
|
+
):
|
|
865
|
+
operation_id = uuid.uuid4().hex
|
|
866
|
+
elif operation_is_registered(
|
|
867
|
+
request.app.state,
|
|
868
|
+
session.session_id,
|
|
869
|
+
operation_id,
|
|
870
|
+
):
|
|
871
|
+
raise HTTPException(
|
|
872
|
+
status_code=409,
|
|
873
|
+
detail=f"Operation already active: {operation_id}",
|
|
874
|
+
)
|
|
875
|
+
task = asyncio.create_task(_run())
|
|
876
|
+
track_task(
|
|
877
|
+
request.app.state,
|
|
878
|
+
session.session_id,
|
|
879
|
+
task,
|
|
880
|
+
operation_id=operation_id,
|
|
881
|
+
operation_scope="foreground",
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
return {
|
|
885
|
+
"status": "started",
|
|
886
|
+
"session_id": session.session_id,
|
|
887
|
+
"operation_id": operation_id,
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@router.post("/compact")
|
|
892
|
+
async def compact_session(req: CompactRequest, request: Request):
|
|
893
|
+
"""Manually trigger conversation compaction."""
|
|
894
|
+
session = _get_session(request, req.session_id)
|
|
895
|
+
if not session:
|
|
896
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
897
|
+
|
|
898
|
+
try:
|
|
899
|
+
accepted = await run_session_operation(
|
|
900
|
+
request.app.state,
|
|
901
|
+
session,
|
|
902
|
+
lambda: session.compact(req.custom_instructions),
|
|
903
|
+
)
|
|
904
|
+
except SessionOperationRejected as exc:
|
|
905
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
906
|
+
return {"status": "ok" if accepted else "not_compacted"}
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@router.post("/clear")
|
|
910
|
+
async def clear_session(req: ClearSessionRequest, request: Request):
|
|
911
|
+
"""Clear and durably persist the active conversation projection."""
|
|
912
|
+
session = _get_session(request, req.session_id)
|
|
913
|
+
if not session:
|
|
914
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
915
|
+
|
|
916
|
+
try:
|
|
917
|
+
cleared = await run_session_operation(
|
|
918
|
+
request.app.state,
|
|
919
|
+
session,
|
|
920
|
+
session.clear_history,
|
|
921
|
+
)
|
|
922
|
+
except SessionOperationRejected as exc:
|
|
923
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
924
|
+
return {"status": "ok", "messages_cleared": cleared}
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
@router.post("/interrupt")
|
|
928
|
+
async def interrupt_session(req: InterruptRequest, request: Request):
|
|
929
|
+
"""Interrupt the current query loop."""
|
|
930
|
+
session = _get_session(request, req.session_id)
|
|
931
|
+
if not session:
|
|
932
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
933
|
+
|
|
934
|
+
if req.operation_id is not None:
|
|
935
|
+
cancelled = await cancel_operation_task(
|
|
936
|
+
request.app.state,
|
|
937
|
+
session.session_id,
|
|
938
|
+
req.operation_id,
|
|
939
|
+
expected_session=session,
|
|
940
|
+
)
|
|
941
|
+
if cancelled is None:
|
|
942
|
+
raise HTTPException(status_code=404, detail="Operation not found")
|
|
943
|
+
task = cancelled.task
|
|
944
|
+
try:
|
|
945
|
+
if not getattr(task, "_crabcode_terminal_published", False):
|
|
946
|
+
from crabcode_core.types.event import TurnCompleteEvent
|
|
947
|
+
|
|
948
|
+
await request.app.state.event_bus.publish(
|
|
949
|
+
session.session_id,
|
|
950
|
+
TurnCompleteEvent(reason="interrupted"),
|
|
951
|
+
source=session,
|
|
952
|
+
operation_id=req.operation_id,
|
|
953
|
+
operation_scope=(
|
|
954
|
+
getattr(task, "_crabcode_operation_scope", None)
|
|
955
|
+
or "foreground"
|
|
956
|
+
),
|
|
957
|
+
)
|
|
958
|
+
setattr(task, "_crabcode_terminal_published", True)
|
|
959
|
+
finally:
|
|
960
|
+
release_operation_claim(
|
|
961
|
+
request.app.state,
|
|
962
|
+
session.session_id,
|
|
963
|
+
req.operation_id,
|
|
964
|
+
cancelled.claim,
|
|
965
|
+
)
|
|
966
|
+
return {
|
|
967
|
+
"status": "ok",
|
|
968
|
+
"operation_id": req.operation_id,
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
try:
|
|
972
|
+
await run_session_operation(
|
|
973
|
+
request.app.state,
|
|
974
|
+
session,
|
|
975
|
+
session.interrupt,
|
|
976
|
+
)
|
|
977
|
+
except SessionOperationRejected as exc:
|
|
978
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
979
|
+
return {"status": "ok"}
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
@router.get("/status", response_model=SessionRuntimeStatus)
|
|
983
|
+
async def session_status(
|
|
984
|
+
request: Request,
|
|
985
|
+
session_id: str | None = None,
|
|
986
|
+
) -> SessionRuntimeStatus:
|
|
987
|
+
"""Return the complete non-secret runtime status for one session."""
|
|
988
|
+
async with get_session_lock(request.app.state):
|
|
989
|
+
session = _get_session(request, session_id)
|
|
990
|
+
if not session:
|
|
991
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
992
|
+
|
|
993
|
+
messages = list(getattr(session, "messages", ()))
|
|
994
|
+
initialized = bool(getattr(session, "_initialized", False))
|
|
995
|
+
current_name = getattr(session, "_current_model_name", None)
|
|
996
|
+
settings = getattr(session, "settings", None)
|
|
997
|
+
active_config = None
|
|
998
|
+
if settings is not None and hasattr(settings, "get_api_config"):
|
|
999
|
+
active_config = settings.get_api_config(current_name)
|
|
1000
|
+
model = active_config.model or ""
|
|
1001
|
+
provider = active_config.provider or ""
|
|
1002
|
+
else:
|
|
1003
|
+
# Retain compatibility with lightweight integration doubles and
|
|
1004
|
+
# older session implementations that expose flat attributes.
|
|
1005
|
+
model = getattr(session, "model", "")
|
|
1006
|
+
provider = getattr(session, "provider", "")
|
|
1007
|
+
mode = getattr(session, "agent_mode", getattr(session, "mode", "agent"))
|
|
1008
|
+
reasoning_effort = getattr(session, "reasoning_effort", None)
|
|
1009
|
+
ultra_mode = bool(getattr(session, "ultra_mode", False))
|
|
1010
|
+
permission_mode = getattr(session, "client_permission_mode", "default")
|
|
1011
|
+
permission_manager = getattr(session, "_permission_manager", None)
|
|
1012
|
+
describe_policy = getattr(permission_manager, "describe_policy", None)
|
|
1013
|
+
permission_policy = describe_policy() if callable(describe_policy) else None
|
|
1014
|
+
sid = session.session_id
|
|
1015
|
+
|
|
1016
|
+
enabled_tools = None
|
|
1017
|
+
if initialized:
|
|
1018
|
+
enabled_tools = sum(
|
|
1019
|
+
1 for tool in getattr(session, "tools", ())
|
|
1020
|
+
if bool(getattr(tool, "is_enabled", True))
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
agents = list(session.list_agents())
|
|
1025
|
+
except (AttributeError, RuntimeError):
|
|
1026
|
+
agents = []
|
|
1027
|
+
try:
|
|
1028
|
+
monitors = list(session.list_monitor_tasks())
|
|
1029
|
+
except (AttributeError, RuntimeError):
|
|
1030
|
+
monitors = []
|
|
1031
|
+
|
|
1032
|
+
token_source = getattr(session, "last_context_token_source", "estimated")
|
|
1033
|
+
used = max(0, int(getattr(session, "last_context_used_tokens", 0) or 0))
|
|
1034
|
+
window = max(0, int(getattr(session, "last_context_window_tokens", 0) or 0))
|
|
1035
|
+
if not used and messages:
|
|
1036
|
+
from crabcode_core.compact.compact import estimate_token_count
|
|
1037
|
+
|
|
1038
|
+
used = max(0, int(estimate_token_count(messages)))
|
|
1039
|
+
token_source = "estimated"
|
|
1040
|
+
if not window and settings is not None:
|
|
1041
|
+
from crabcode_core.api.model_info import (
|
|
1042
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
1043
|
+
lookup_context_window,
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
window = max(
|
|
1047
|
+
0,
|
|
1048
|
+
int(
|
|
1049
|
+
getattr(active_config, "context_window", None)
|
|
1050
|
+
or 0
|
|
1051
|
+
),
|
|
1052
|
+
)
|
|
1053
|
+
adapter = getattr(session, "_api_adapter", None)
|
|
1054
|
+
if not window and provider == "codex" and hasattr(
|
|
1055
|
+
adapter, "resolve_context_window"
|
|
1056
|
+
):
|
|
1057
|
+
try:
|
|
1058
|
+
window = max(0, int(await adapter.resolve_context_window()))
|
|
1059
|
+
except Exception:
|
|
1060
|
+
window = 0
|
|
1061
|
+
if not window:
|
|
1062
|
+
window = max(
|
|
1063
|
+
0,
|
|
1064
|
+
int(
|
|
1065
|
+
lookup_context_window(getattr(active_config, "model", None))
|
|
1066
|
+
or DEFAULT_CONTEXT_WINDOW
|
|
1067
|
+
),
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
search_index = None
|
|
1071
|
+
extra_tools = list(getattr(settings, "extra_tools", ()) or ())
|
|
1072
|
+
if "crabcode_search.CodebaseSearchTool" in extra_tools:
|
|
1073
|
+
try:
|
|
1074
|
+
from crabcode_search.background import read_background_status
|
|
1075
|
+
|
|
1076
|
+
raw_search = read_background_status(getattr(session, "cwd", os.getcwd()))
|
|
1077
|
+
except Exception:
|
|
1078
|
+
raw_search = None
|
|
1079
|
+
if isinstance(raw_search, dict):
|
|
1080
|
+
def _optional_int(key: str) -> int | None:
|
|
1081
|
+
value = raw_search.get(key)
|
|
1082
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
1083
|
+
|
|
1084
|
+
search_index = SearchIndexStatus(
|
|
1085
|
+
state=str(raw_search.get("state") or "unknown"),
|
|
1086
|
+
chunks=_optional_int("chunks"),
|
|
1087
|
+
files=_optional_int("files"),
|
|
1088
|
+
done=_optional_int("done"),
|
|
1089
|
+
total=_optional_int("total"),
|
|
1090
|
+
)
|
|
1091
|
+
else:
|
|
1092
|
+
search_index = SearchIndexStatus(state="waiting")
|
|
1093
|
+
|
|
1094
|
+
agent_settings = getattr(settings, "agent", None)
|
|
1095
|
+
from crabcode_core.compact.compact import compaction_input_limit
|
|
1096
|
+
from crabcode_core.types.config import DEFAULT_COMPACT_BUFFER_TOKENS
|
|
1097
|
+
|
|
1098
|
+
compact_buffer = getattr(settings, "compact_buffer_tokens", DEFAULT_COMPACT_BUFFER_TOKENS)
|
|
1099
|
+
compact_override = getattr(settings, "max_context_length", None)
|
|
1100
|
+
max_output = max(0, int(getattr(active_config, "max_tokens", 0) or 0))
|
|
1101
|
+
result = SessionRuntimeStatus(
|
|
1102
|
+
session_id=sid,
|
|
1103
|
+
cwd=str(getattr(session, "cwd", "") or ""),
|
|
1104
|
+
initialized=initialized,
|
|
1105
|
+
message_count=len(messages),
|
|
1106
|
+
model=model,
|
|
1107
|
+
model_profile=current_name,
|
|
1108
|
+
provider=provider,
|
|
1109
|
+
mode=mode if mode in {"agent", "plan"} else "agent",
|
|
1110
|
+
reasoning_effort=reasoning_effort,
|
|
1111
|
+
ultra_mode=ultra_mode,
|
|
1112
|
+
permission_mode=permission_mode,
|
|
1113
|
+
permission_policy=permission_policy,
|
|
1114
|
+
context_used_tokens=used,
|
|
1115
|
+
context_token_source=token_source,
|
|
1116
|
+
context_window_tokens=window,
|
|
1117
|
+
context_remaining_tokens=max(0, window - used),
|
|
1118
|
+
context_used_percent=round(used / window * 100, 1) if window else 0.0,
|
|
1119
|
+
compact_count=max(0, int(getattr(session, "compact_count", 0) or 0)),
|
|
1120
|
+
auto_compact_enabled=bool(
|
|
1121
|
+
getattr(settings, "auto_compact_enabled", True)
|
|
1122
|
+
),
|
|
1123
|
+
compact_buffer_tokens=compact_buffer,
|
|
1124
|
+
max_context_length=compact_override,
|
|
1125
|
+
compact_input_limit=compaction_input_limit(
|
|
1126
|
+
window, max_output, buffer_tokens=compact_buffer, override=compact_override,
|
|
1127
|
+
) if window else None,
|
|
1128
|
+
thinking_enabled=bool(getattr(active_config, "thinking_enabled", False)),
|
|
1129
|
+
max_tokens=max_output,
|
|
1130
|
+
tool_count=enabled_tools,
|
|
1131
|
+
prompt_budget=getattr(session, "last_prompt_budget", None) or None,
|
|
1132
|
+
agent_total=len(agents),
|
|
1133
|
+
agent_active=sum(
|
|
1134
|
+
1 for item in agents
|
|
1135
|
+
if getattr(item, "status", "") in {"queued", "running"}
|
|
1136
|
+
),
|
|
1137
|
+
agent_failed=sum(
|
|
1138
|
+
1 for item in agents if getattr(item, "status", "") == "failed"
|
|
1139
|
+
),
|
|
1140
|
+
agent_pending_callbacks=sum(
|
|
1141
|
+
1 for item in agents
|
|
1142
|
+
if bool(getattr(item, "callback_enabled", False))
|
|
1143
|
+
and getattr(item, "callback_state", "") in {"pending", "injected"}
|
|
1144
|
+
),
|
|
1145
|
+
agent_max_concurrency=max(
|
|
1146
|
+
0,
|
|
1147
|
+
int(getattr(agent_settings, "max_concurrency", 0) or 0),
|
|
1148
|
+
),
|
|
1149
|
+
monitor_total=len(monitors),
|
|
1150
|
+
monitor_active=sum(
|
|
1151
|
+
1 for item in monitors if getattr(item, "status", "") == "running"
|
|
1152
|
+
),
|
|
1153
|
+
monitor_failed=sum(
|
|
1154
|
+
1 for item in monitors if getattr(item, "status", "") == "failed"
|
|
1155
|
+
),
|
|
1156
|
+
search_index=search_index,
|
|
1157
|
+
)
|
|
1158
|
+
return result
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
@router.get("/recent", response_model=list[SessionInfo])
|
|
1162
|
+
async def recent_sessions(limit: int = 10, request: Request = None):
|
|
1163
|
+
"""List recently updated sessions across all projects."""
|
|
1164
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1165
|
+
|
|
1166
|
+
try:
|
|
1167
|
+
store = SessionMetaStore()
|
|
1168
|
+
try:
|
|
1169
|
+
rows = store.list_recent(limit=max(1, min(200, limit)))
|
|
1170
|
+
finally:
|
|
1171
|
+
store.close()
|
|
1172
|
+
except Exception as exc:
|
|
1173
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1174
|
+
return [_session_info_from_row(row) for row in rows]
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
@router.post("/search", response_model=list[SessionInfo])
|
|
1178
|
+
async def search_sessions(req: SearchSessionsRequest, request: Request):
|
|
1179
|
+
"""Search sessions by title or first message content."""
|
|
1180
|
+
from crabcode_core.session.storage import SessionStorage
|
|
1181
|
+
|
|
1182
|
+
try:
|
|
1183
|
+
rows = SessionStorage.search_sessions(req.query, limit=req.limit)
|
|
1184
|
+
except Exception:
|
|
1185
|
+
rows = []
|
|
1186
|
+
|
|
1187
|
+
return [_session_info_from_row(row) for row in rows]
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
@router.post("/archive")
|
|
1191
|
+
async def archive_session(req: ArchiveSessionRequest, request: Request):
|
|
1192
|
+
"""Archive a loaded or persisted session selected by ID/prefix/index."""
|
|
1193
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1194
|
+
from crabcode_core.session.storage import SessionStorage
|
|
1195
|
+
|
|
1196
|
+
if not req.session_id:
|
|
1197
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1198
|
+
|
|
1199
|
+
session = None
|
|
1200
|
+
resolved_session_id = ""
|
|
1201
|
+
async with get_session_load_lock(request.app.state):
|
|
1202
|
+
async with get_session_lock(request.app.state):
|
|
1203
|
+
sessions: dict = request.app.state.sessions
|
|
1204
|
+
loaded = dict(sessions)
|
|
1205
|
+
default_id = request.app.state.default_session_id
|
|
1206
|
+
cwd = os.getcwd()
|
|
1207
|
+
if default_id and default_id in sessions:
|
|
1208
|
+
cwd = str(getattr(sessions[default_id], "cwd", cwd))
|
|
1209
|
+
|
|
1210
|
+
try:
|
|
1211
|
+
resolved = _resolve_session_selector(
|
|
1212
|
+
req.session_id,
|
|
1213
|
+
cwd,
|
|
1214
|
+
loaded_sessions=loaded,
|
|
1215
|
+
)
|
|
1216
|
+
except Exception as exc:
|
|
1217
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1218
|
+
if resolved is None:
|
|
1219
|
+
raise HTTPException(
|
|
1220
|
+
status_code=404,
|
|
1221
|
+
detail="Session not found or selector is ambiguous",
|
|
1222
|
+
)
|
|
1223
|
+
resolved_session_id, resolved_cwd = resolved
|
|
1224
|
+
_validate_session_id(resolved_session_id)
|
|
1225
|
+
|
|
1226
|
+
async with get_session_lock(request.app.state):
|
|
1227
|
+
if resolved_session_id in getattr(
|
|
1228
|
+
request.app.state,
|
|
1229
|
+
"closing_sessions",
|
|
1230
|
+
set(),
|
|
1231
|
+
):
|
|
1232
|
+
raise HTTPException(status_code=503, detail="Session is shutting down")
|
|
1233
|
+
session = request.app.state.sessions.get(resolved_session_id)
|
|
1234
|
+
mark_session_closing(request.app.state, resolved_session_id)
|
|
1235
|
+
|
|
1236
|
+
archive_committed = False
|
|
1237
|
+
try:
|
|
1238
|
+
store = SessionMetaStore()
|
|
1239
|
+
try:
|
|
1240
|
+
row = store.get(resolved_session_id)
|
|
1241
|
+
if row is not None:
|
|
1242
|
+
store.archive(resolved_session_id)
|
|
1243
|
+
else:
|
|
1244
|
+
SessionStorage(
|
|
1245
|
+
resolved_cwd,
|
|
1246
|
+
resolved_session_id,
|
|
1247
|
+
).persist_archive_marker()
|
|
1248
|
+
finally:
|
|
1249
|
+
store.close()
|
|
1250
|
+
archive_committed = True
|
|
1251
|
+
except Exception as exc:
|
|
1252
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1253
|
+
finally:
|
|
1254
|
+
if not archive_committed:
|
|
1255
|
+
async with get_session_lock(request.app.state):
|
|
1256
|
+
unmark_session_closing(
|
|
1257
|
+
request.app.state,
|
|
1258
|
+
resolved_session_id,
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
async with get_session_lock(request.app.state):
|
|
1262
|
+
sessions = request.app.state.sessions
|
|
1263
|
+
session = sessions.get(resolved_session_id)
|
|
1264
|
+
if session is not None:
|
|
1265
|
+
close_session_events = getattr(
|
|
1266
|
+
request.app.state.event_bus,
|
|
1267
|
+
"close_session",
|
|
1268
|
+
None,
|
|
1269
|
+
)
|
|
1270
|
+
if callable(close_session_events):
|
|
1271
|
+
close_session_events(resolved_session_id, session)
|
|
1272
|
+
sessions.pop(resolved_session_id, None)
|
|
1273
|
+
request.app.state.client_contexts.pop(resolved_session_id, None)
|
|
1274
|
+
if request.app.state.default_session_id == resolved_session_id:
|
|
1275
|
+
request.app.state.default_session_id = next(iter(sessions), None)
|
|
1276
|
+
|
|
1277
|
+
if session is not None:
|
|
1278
|
+
try:
|
|
1279
|
+
await shielded_cleanup_session(
|
|
1280
|
+
request.app.state,
|
|
1281
|
+
resolved_session_id,
|
|
1282
|
+
session,
|
|
1283
|
+
)
|
|
1284
|
+
except Exception:
|
|
1285
|
+
from crabcode_core.logging_utils import get_logger
|
|
1286
|
+
get_logger(__name__).warning(
|
|
1287
|
+
"Failed to close archived session %s",
|
|
1288
|
+
resolved_session_id,
|
|
1289
|
+
exc_info=True,
|
|
1290
|
+
)
|
|
1291
|
+
else:
|
|
1292
|
+
unmark_session_closing(request.app.state, resolved_session_id)
|
|
1293
|
+
return {"status": "ok", "session_id": resolved_session_id}
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
@router.post("/prune")
|
|
1297
|
+
async def prune_sessions(req: PruneSessionsRequest, request: Request):
|
|
1298
|
+
"""Archive stale inactive sessions and optionally purge their artifacts.
|
|
1299
|
+
|
|
1300
|
+
Loaded sessions are always excluded. The load lock prevents a concurrent
|
|
1301
|
+
resume from installing one of the selected sessions between discovery and
|
|
1302
|
+
its durable archive/purge transition.
|
|
1303
|
+
"""
|
|
1304
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1305
|
+
from crabcode_core.session.storage import purge_session_artifacts
|
|
1306
|
+
|
|
1307
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
1308
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
1309
|
+
|
|
1310
|
+
async with get_session_load_lock(request.app.state):
|
|
1311
|
+
async with get_session_lock(request.app.state):
|
|
1312
|
+
if getattr(request.app.state, "gateway_closing", False):
|
|
1313
|
+
raise HTTPException(status_code=503, detail="Gateway is shutting down")
|
|
1314
|
+
loaded_ids = set(request.app.state.sessions)
|
|
1315
|
+
|
|
1316
|
+
store = SessionMetaStore()
|
|
1317
|
+
try:
|
|
1318
|
+
archived = store.auto_archive(
|
|
1319
|
+
days=req.days,
|
|
1320
|
+
exclude_ids=loaded_ids,
|
|
1321
|
+
)
|
|
1322
|
+
purged = 0
|
|
1323
|
+
failed: list[str] = []
|
|
1324
|
+
if req.delete_files:
|
|
1325
|
+
candidates = store.purge_archived(
|
|
1326
|
+
delete_rows=False,
|
|
1327
|
+
exclude_ids=loaded_ids,
|
|
1328
|
+
)
|
|
1329
|
+
for entry in candidates:
|
|
1330
|
+
session_id = str(entry.get("id") or "")
|
|
1331
|
+
cwd = str(entry.get("cwd") or "")
|
|
1332
|
+
if not session_id:
|
|
1333
|
+
continue
|
|
1334
|
+
try:
|
|
1335
|
+
if cwd:
|
|
1336
|
+
purge_session_artifacts(cwd, session_id)
|
|
1337
|
+
store.delete(session_id)
|
|
1338
|
+
purged += 1
|
|
1339
|
+
except (OSError, ValueError):
|
|
1340
|
+
failed.append(session_id)
|
|
1341
|
+
finally:
|
|
1342
|
+
store.close()
|
|
1343
|
+
|
|
1344
|
+
return {
|
|
1345
|
+
"archived": archived,
|
|
1346
|
+
"purged": purged,
|
|
1347
|
+
"failed": failed,
|
|
1348
|
+
"skipped_loaded": len(loaded_ids),
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
@router.post("/export")
|
|
1353
|
+
async def export_session(req: ExportSessionRequest, request: Request):
|
|
1354
|
+
"""Export a session selected by ID, unique prefix, or local index."""
|
|
1355
|
+
from crabcode_core.session.export import export_json, export_markdown
|
|
1356
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1357
|
+
from crabcode_core.session.storage import SessionStorage, get_transcript_path
|
|
1358
|
+
|
|
1359
|
+
if not req.session_id:
|
|
1360
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1361
|
+
|
|
1362
|
+
async with get_session_lock(request.app.state):
|
|
1363
|
+
sessions: dict = request.app.state.sessions
|
|
1364
|
+
loaded = dict(sessions)
|
|
1365
|
+
default_id = request.app.state.default_session_id
|
|
1366
|
+
selector_cwd = os.getcwd()
|
|
1367
|
+
if default_id and default_id in sessions:
|
|
1368
|
+
selector_cwd = str(getattr(sessions[default_id], "cwd", selector_cwd))
|
|
1369
|
+
|
|
1370
|
+
try:
|
|
1371
|
+
resolved_selector = _resolve_session_selector(
|
|
1372
|
+
req.session_id,
|
|
1373
|
+
selector_cwd,
|
|
1374
|
+
loaded_sessions=loaded,
|
|
1375
|
+
)
|
|
1376
|
+
except Exception as exc:
|
|
1377
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1378
|
+
if resolved_selector is None:
|
|
1379
|
+
# Archived sessions are omitted from normal selectors, but a caller
|
|
1380
|
+
# holding the exact ID must still be able to export the retained audit
|
|
1381
|
+
# transcript. Prefixes and indexes intentionally remain active-only.
|
|
1382
|
+
exact_id = req.session_id.strip()
|
|
1383
|
+
try:
|
|
1384
|
+
_validate_session_id(exact_id)
|
|
1385
|
+
except HTTPException:
|
|
1386
|
+
raise
|
|
1387
|
+
store = SessionMetaStore()
|
|
1388
|
+
try:
|
|
1389
|
+
archived_row = store.get(exact_id)
|
|
1390
|
+
finally:
|
|
1391
|
+
store.close()
|
|
1392
|
+
archived_cwd = (
|
|
1393
|
+
str(archived_row.get("cwd") or selector_cwd)
|
|
1394
|
+
if archived_row is not None and archived_row.get("is_archived")
|
|
1395
|
+
else selector_cwd
|
|
1396
|
+
)
|
|
1397
|
+
transcript = get_transcript_path(archived_cwd, exact_id)
|
|
1398
|
+
if not transcript.exists() or (
|
|
1399
|
+
archived_row is not None and not archived_row.get("is_archived")
|
|
1400
|
+
):
|
|
1401
|
+
raise HTTPException(
|
|
1402
|
+
status_code=404,
|
|
1403
|
+
detail="Session not found or selector is ambiguous",
|
|
1404
|
+
)
|
|
1405
|
+
resolved_selector = (exact_id, archived_cwd)
|
|
1406
|
+
session_id, cwd = resolved_selector
|
|
1407
|
+
_validate_session_id(session_id)
|
|
1408
|
+
|
|
1409
|
+
async with get_session_lock(request.app.state):
|
|
1410
|
+
active_session = _get_session(request, session_id)
|
|
1411
|
+
if active_session is not None:
|
|
1412
|
+
cwd = str(getattr(active_session, "cwd", cwd))
|
|
1413
|
+
|
|
1414
|
+
if active_session is None:
|
|
1415
|
+
# Prefer the persisted project recorded in SQLite. This matters when
|
|
1416
|
+
# the requested session belongs to another project than the active one.
|
|
1417
|
+
resolved_storage = SessionStorage.from_session_id(session_id)
|
|
1418
|
+
if resolved_storage is not None:
|
|
1419
|
+
cwd = resolved_storage.cwd
|
|
1420
|
+
|
|
1421
|
+
# SQLite may be unavailable while the JSONL transcript is still
|
|
1422
|
+
# present. Check the local candidate before declaring the id missing.
|
|
1423
|
+
try:
|
|
1424
|
+
transcript = get_transcript_path(cwd, session_id)
|
|
1425
|
+
if resolved_storage is None and not transcript.exists():
|
|
1426
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1427
|
+
except HTTPException:
|
|
1428
|
+
raise
|
|
1429
|
+
except (TypeError, ValueError) as exc:
|
|
1430
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
1431
|
+
|
|
1432
|
+
async def _render_export():
|
|
1433
|
+
if req.format == "json":
|
|
1434
|
+
return (
|
|
1435
|
+
export_json(session_id, cwd),
|
|
1436
|
+
"application/json",
|
|
1437
|
+
f"session-{session_id[:8]}.json",
|
|
1438
|
+
)
|
|
1439
|
+
return (
|
|
1440
|
+
export_markdown(session_id, cwd),
|
|
1441
|
+
"text/markdown",
|
|
1442
|
+
f"session-{session_id[:8]}.md",
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
try:
|
|
1446
|
+
if active_session is not None:
|
|
1447
|
+
rendered = await run_session_operation(
|
|
1448
|
+
request.app.state,
|
|
1449
|
+
active_session,
|
|
1450
|
+
_render_export,
|
|
1451
|
+
)
|
|
1452
|
+
else:
|
|
1453
|
+
rendered = await _render_export()
|
|
1454
|
+
content, media_type, filename = rendered
|
|
1455
|
+
except SessionOperationRejected as exc:
|
|
1456
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1457
|
+
except Exception as exc:
|
|
1458
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
1459
|
+
|
|
1460
|
+
from fastapi.responses import Response
|
|
1461
|
+
return Response(
|
|
1462
|
+
content=content,
|
|
1463
|
+
media_type=media_type,
|
|
1464
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
@router.get("/stats")
|
|
1469
|
+
async def session_stats(request: Request):
|
|
1470
|
+
"""Return usage statistics: global totals and per-project breakdown."""
|
|
1471
|
+
import os
|
|
1472
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1473
|
+
|
|
1474
|
+
async with get_session_lock(request.app.state):
|
|
1475
|
+
sessions: dict = request.app.state.sessions
|
|
1476
|
+
default_id = request.app.state.default_session_id
|
|
1477
|
+
cwd = os.getcwd()
|
|
1478
|
+
if default_id and default_id in sessions:
|
|
1479
|
+
cwd = getattr(sessions[default_id], "cwd", cwd)
|
|
1480
|
+
|
|
1481
|
+
try:
|
|
1482
|
+
store = SessionMetaStore()
|
|
1483
|
+
try:
|
|
1484
|
+
global_stats = store.stats_global()
|
|
1485
|
+
project_stats = store.stats_by_project(cwd)
|
|
1486
|
+
model_stats = store.stats_by_model()
|
|
1487
|
+
finally:
|
|
1488
|
+
store.close()
|
|
1489
|
+
except Exception as exc:
|
|
1490
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
1491
|
+
|
|
1492
|
+
return {
|
|
1493
|
+
"global": global_stats,
|
|
1494
|
+
"project": {**project_stats, "cwd": cwd},
|
|
1495
|
+
"by_model": model_stats,
|
|
1496
|
+
}
|