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,211 @@
|
|
|
1
|
+
"""Snapshot and revert routes — /snapshot/*."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
9
|
+
|
|
10
|
+
from crabcode_gateway.schemas import CheckpointRequest, RevertRequest
|
|
11
|
+
from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/snapshot", tags=["snapshot"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _checkpoint(session: Any, label: str) -> tuple[str | None, bool]:
|
|
17
|
+
checkpoint_id = await asyncio.to_thread(session.checkpoint, label=label)
|
|
18
|
+
# Read the status in the same coroutine immediately after the worker
|
|
19
|
+
# returns, so concurrent checkpoint requests cannot overwrite the status
|
|
20
|
+
# before this request packages its response.
|
|
21
|
+
return checkpoint_id, bool(
|
|
22
|
+
getattr(session, "last_checkpoint_snapshot_included", False)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _list_checkpoints(session: Any) -> list[dict[str, Any]]:
|
|
27
|
+
return session.list_checkpoints()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_checkpoint_id(checkpoints: list[dict[str, Any]], selector: str) -> str | None:
|
|
31
|
+
"""Resolve a full ID, unique prefix, or one-based checkpoint index."""
|
|
32
|
+
value = selector.strip()
|
|
33
|
+
if not value:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
index = int(value) - 1
|
|
37
|
+
except ValueError:
|
|
38
|
+
index = -1
|
|
39
|
+
if 0 <= index < len(checkpoints):
|
|
40
|
+
return str(checkpoints[index].get("id") or "") or None
|
|
41
|
+
exact = [str(cp.get("id") or "") for cp in checkpoints if cp.get("id") == value]
|
|
42
|
+
if exact:
|
|
43
|
+
return exact[0]
|
|
44
|
+
matches = [str(cp.get("id") or "") for cp in checkpoints if str(cp.get("id") or "").startswith(value)]
|
|
45
|
+
return matches[0] if len(matches) == 1 else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _resolve_and_revert(
|
|
49
|
+
session: Any,
|
|
50
|
+
selector: str,
|
|
51
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
52
|
+
checkpoint_id = _resolve_checkpoint_id(session.list_checkpoints(), selector)
|
|
53
|
+
if checkpoint_id is None:
|
|
54
|
+
return None
|
|
55
|
+
return checkpoint_id, session.revert(checkpoint_id)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _resolve_and_rollback(
|
|
59
|
+
session: Any,
|
|
60
|
+
selector: str,
|
|
61
|
+
) -> tuple[str, bool, int] | None:
|
|
62
|
+
checkpoint_id = _resolve_checkpoint_id(session.list_checkpoints(), selector)
|
|
63
|
+
if checkpoint_id is None:
|
|
64
|
+
return None
|
|
65
|
+
ok = bool(session.rollback(checkpoint_id))
|
|
66
|
+
return checkpoint_id, ok, len(getattr(session, "messages", ()))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _undo_latest(session: Any) -> tuple[str, dict[str, Any]] | None:
|
|
70
|
+
checkpoints = session.list_checkpoints()
|
|
71
|
+
if not checkpoints:
|
|
72
|
+
return None
|
|
73
|
+
checkpoint_id = str(checkpoints[0].get("id") or "")
|
|
74
|
+
if not checkpoint_id:
|
|
75
|
+
return None
|
|
76
|
+
return checkpoint_id, session.revert(checkpoint_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_session(request: Request, session_id: str):
|
|
80
|
+
"""Retrieve a CoreSession from app state."""
|
|
81
|
+
sessions: dict = request.app.state.sessions
|
|
82
|
+
if session_id is None or session_id not in sessions:
|
|
83
|
+
return None
|
|
84
|
+
if session_id in getattr(request.app.state, "closing_sessions", set()):
|
|
85
|
+
return None
|
|
86
|
+
return sessions[session_id]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.post("/checkpoint")
|
|
90
|
+
async def create_checkpoint(req: CheckpointRequest, request: Request) -> dict[str, Any]:
|
|
91
|
+
"""Create a checkpoint with a file-system snapshot."""
|
|
92
|
+
session = _get_session(request, req.session_id)
|
|
93
|
+
if not session:
|
|
94
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
95
|
+
try:
|
|
96
|
+
cp_id, snapshot_included = await run_session_operation(
|
|
97
|
+
request.app.state,
|
|
98
|
+
session,
|
|
99
|
+
lambda: _checkpoint(session, req.label),
|
|
100
|
+
)
|
|
101
|
+
except SessionOperationRejected as exc:
|
|
102
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
103
|
+
if not cp_id:
|
|
104
|
+
raise HTTPException(status_code=400, detail="Failed to create checkpoint")
|
|
105
|
+
return {
|
|
106
|
+
"checkpoint_id": cp_id,
|
|
107
|
+
"snapshot_included": snapshot_included,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.get("/list")
|
|
112
|
+
async def list_checkpoints(session_id: str, request: Request) -> list[dict[str, Any]]:
|
|
113
|
+
"""List checkpoints for a session, including file snapshot info."""
|
|
114
|
+
session = _get_session(request, session_id)
|
|
115
|
+
if not session:
|
|
116
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
117
|
+
try:
|
|
118
|
+
return await run_session_operation(
|
|
119
|
+
request.app.state,
|
|
120
|
+
session,
|
|
121
|
+
lambda: _list_checkpoints(session),
|
|
122
|
+
)
|
|
123
|
+
except SessionOperationRejected as exc:
|
|
124
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.post("/revert")
|
|
128
|
+
async def revert_checkpoint(req: RevertRequest, request: Request) -> dict[str, Any]:
|
|
129
|
+
"""Revert both files and conversation to a checkpoint."""
|
|
130
|
+
session = _get_session(request, req.session_id)
|
|
131
|
+
if not session:
|
|
132
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
133
|
+
try:
|
|
134
|
+
resolved = await run_session_operation(
|
|
135
|
+
request.app.state,
|
|
136
|
+
session,
|
|
137
|
+
lambda: _resolve_and_revert(session, req.checkpoint_id),
|
|
138
|
+
)
|
|
139
|
+
except SessionOperationRejected as exc:
|
|
140
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
141
|
+
if resolved is None:
|
|
142
|
+
raise HTTPException(status_code=400, detail="Checkpoint not found or prefix is ambiguous")
|
|
143
|
+
_checkpoint_id, result = resolved
|
|
144
|
+
if not result.get("success"):
|
|
145
|
+
raise HTTPException(status_code=400, detail="Revert failed or checkpoint not found")
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@router.post("/rollback")
|
|
150
|
+
async def rollback_checkpoint(req: RevertRequest, request: Request) -> dict[str, Any]:
|
|
151
|
+
"""Rollback conversation only (no file restore) to a checkpoint."""
|
|
152
|
+
session = _get_session(request, req.session_id)
|
|
153
|
+
if not session:
|
|
154
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
155
|
+
try:
|
|
156
|
+
resolved = await run_session_operation(
|
|
157
|
+
request.app.state,
|
|
158
|
+
session,
|
|
159
|
+
lambda: _resolve_and_rollback(session, req.checkpoint_id),
|
|
160
|
+
)
|
|
161
|
+
except SessionOperationRejected as exc:
|
|
162
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
163
|
+
if resolved is None:
|
|
164
|
+
raise HTTPException(status_code=400, detail="Checkpoint not found or prefix is ambiguous")
|
|
165
|
+
_checkpoint_id, ok, message_count = resolved
|
|
166
|
+
if not ok:
|
|
167
|
+
raise HTTPException(status_code=400, detail="Rollback failed or checkpoint not found")
|
|
168
|
+
return {"success": True, "messages_count": message_count}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.post("/undo")
|
|
172
|
+
async def undo_last_checkpoint(request: Request) -> dict[str, Any]:
|
|
173
|
+
"""Revert files and conversation to the most recent checkpoint."""
|
|
174
|
+
try:
|
|
175
|
+
body = await request.json()
|
|
176
|
+
except (ValueError, TypeError) as exc:
|
|
177
|
+
raise HTTPException(status_code=400, detail="Request body must be valid JSON") from exc
|
|
178
|
+
if body is None:
|
|
179
|
+
body = {}
|
|
180
|
+
if not isinstance(body, dict):
|
|
181
|
+
raise HTTPException(status_code=400, detail="Request body must be a JSON object")
|
|
182
|
+
session_id = body.get("session_id") if body else None
|
|
183
|
+
|
|
184
|
+
sessions: dict = request.app.state.sessions
|
|
185
|
+
sid = (
|
|
186
|
+
request.app.state.default_session_id
|
|
187
|
+
if session_id is None
|
|
188
|
+
else session_id
|
|
189
|
+
)
|
|
190
|
+
if (
|
|
191
|
+
not sid
|
|
192
|
+
or sid not in sessions
|
|
193
|
+
or sid in getattr(request.app.state, "closing_sessions", set())
|
|
194
|
+
):
|
|
195
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
196
|
+
session = sessions[sid]
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
resolved = await run_session_operation(
|
|
200
|
+
request.app.state,
|
|
201
|
+
session,
|
|
202
|
+
lambda: _undo_latest(session),
|
|
203
|
+
)
|
|
204
|
+
except SessionOperationRejected as exc:
|
|
205
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
206
|
+
if resolved is None:
|
|
207
|
+
raise HTTPException(status_code=400, detail="No checkpoints to undo")
|
|
208
|
+
checkpoint_id, result = resolved
|
|
209
|
+
if not result.get("success"):
|
|
210
|
+
raise HTTPException(status_code=400, detail="Undo failed")
|
|
211
|
+
return {"checkpoint_id": checkpoint_id, **result}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Background monitor and managed-agent task routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
8
|
+
|
|
9
|
+
from crabcode_gateway.schemas import BackgroundTaskInfo, TaskStopRequest
|
|
10
|
+
from crabcode_gateway.safe_file import read_regular_file_tail
|
|
11
|
+
from crabcode_gateway.session_registry import get_session_lock
|
|
12
|
+
from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
router = APIRouter(tags=["tasks"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _task_dicts(session: Any) -> list[dict[str, Any]]:
|
|
19
|
+
"""Return one stable representation for monitors and managed agents."""
|
|
20
|
+
result: list[dict[str, Any]] = []
|
|
21
|
+
cwd = str(getattr(session, "cwd", "") or "")
|
|
22
|
+
for snapshot in session.list_monitor_tasks():
|
|
23
|
+
data = snapshot.to_dict() if hasattr(snapshot, "to_dict") else dict(snapshot)
|
|
24
|
+
data.setdefault("cwd", cwd)
|
|
25
|
+
result.append(data)
|
|
26
|
+
for snapshot in session.list_agents():
|
|
27
|
+
result.append(
|
|
28
|
+
{
|
|
29
|
+
"task_id": snapshot.agent_id,
|
|
30
|
+
"agent_id": snapshot.agent_id,
|
|
31
|
+
"session_id": snapshot.session_id,
|
|
32
|
+
"cwd": cwd,
|
|
33
|
+
"description": snapshot.title,
|
|
34
|
+
"task_type": "local_agent",
|
|
35
|
+
"source": "agent",
|
|
36
|
+
"status": snapshot.status,
|
|
37
|
+
"output_file": snapshot.transcript_path,
|
|
38
|
+
"created_at": snapshot.created_at,
|
|
39
|
+
"started_at": snapshot.started_at,
|
|
40
|
+
"finished_at": snapshot.finished_at,
|
|
41
|
+
"updated_at": snapshot.updated_at,
|
|
42
|
+
"error": snapshot.error,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _task_info(data: dict[str, Any]) -> BackgroundTaskInfo:
|
|
49
|
+
return BackgroundTaskInfo(
|
|
50
|
+
task_id=str(data.get("task_id") or data.get("agent_id") or ""),
|
|
51
|
+
agent_id=data.get("agent_id"),
|
|
52
|
+
session_id=str(data.get("session_id") or ""),
|
|
53
|
+
cwd=str(data.get("cwd") or ""),
|
|
54
|
+
description=str(data.get("description") or ""),
|
|
55
|
+
task_type=str(data.get("task_type") or ""),
|
|
56
|
+
source=str(data.get("source") or ""),
|
|
57
|
+
status=str(data.get("status") or ""),
|
|
58
|
+
output_file=data.get("output_file"),
|
|
59
|
+
created_at=str(data.get("created_at") or ""),
|
|
60
|
+
started_at=data.get("started_at"),
|
|
61
|
+
finished_at=data.get("finished_at"),
|
|
62
|
+
updated_at=str(data.get("updated_at") or ""),
|
|
63
|
+
error=str(data.get("error") or ""),
|
|
64
|
+
exit_code=data.get("exit_code"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _task_matches(session: Any, selector: str) -> bool:
|
|
69
|
+
value = str(selector or "").strip()
|
|
70
|
+
if not value:
|
|
71
|
+
return False
|
|
72
|
+
return any(
|
|
73
|
+
item.get("task_id", "") == value
|
|
74
|
+
or str(item.get("task_id", "")).startswith(value)
|
|
75
|
+
for item in _task_dicts(session)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_session(request: Request, session_id: str | None = None):
|
|
80
|
+
sessions: dict[str, Any] = request.app.state.sessions
|
|
81
|
+
closing = getattr(request.app.state, "closing_sessions", set())
|
|
82
|
+
|
|
83
|
+
def usable(session: Any) -> bool:
|
|
84
|
+
return session is not None and getattr(session, "session_id", None) not in closing
|
|
85
|
+
|
|
86
|
+
if session_id is not None:
|
|
87
|
+
session = sessions.get(session_id)
|
|
88
|
+
return session if usable(session) else None
|
|
89
|
+
|
|
90
|
+
default = sessions.get(getattr(request.app.state, "default_session_id", None))
|
|
91
|
+
return default if usable(default) else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _get_task_session(
|
|
95
|
+
request: Request,
|
|
96
|
+
session_id: str | None,
|
|
97
|
+
task_id: str,
|
|
98
|
+
):
|
|
99
|
+
"""Resolve cross-session task selectors without inspecting cold sessions."""
|
|
100
|
+
if session_id is not None:
|
|
101
|
+
return _get_session(request, session_id)
|
|
102
|
+
|
|
103
|
+
sessions: dict[str, Any] = request.app.state.sessions
|
|
104
|
+
closing = getattr(request.app.state, "closing_sessions", set())
|
|
105
|
+
default = _get_session(request)
|
|
106
|
+
candidates = ([default] if default is not None else []) + [
|
|
107
|
+
session
|
|
108
|
+
for session in sessions.values()
|
|
109
|
+
if session is not default
|
|
110
|
+
and getattr(session, "session_id", None) not in closing
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
async def _matches(candidate: Any) -> bool:
|
|
114
|
+
initializer = getattr(candidate, "initialize", None)
|
|
115
|
+
if callable(initializer):
|
|
116
|
+
await initializer()
|
|
117
|
+
return _task_matches(candidate, task_id)
|
|
118
|
+
|
|
119
|
+
for candidate in candidates:
|
|
120
|
+
try:
|
|
121
|
+
if await run_session_operation(request.app.state, candidate, lambda: _matches(candidate)):
|
|
122
|
+
return candidate
|
|
123
|
+
except SessionOperationRejected:
|
|
124
|
+
continue
|
|
125
|
+
# Keep historical default-session behavior so callers get a task 404 rather
|
|
126
|
+
# than a misleading session 404 when the selector simply does not exist.
|
|
127
|
+
return default
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _matching_tasks(session: Any, task_id: str) -> list[dict[str, Any]]:
|
|
131
|
+
tasks = _task_dicts(session)
|
|
132
|
+
matches = [item for item in tasks if item.get("task_id") == task_id]
|
|
133
|
+
if not matches:
|
|
134
|
+
matches = [
|
|
135
|
+
item for item in tasks
|
|
136
|
+
if str(item.get("task_id") or "").startswith(task_id)
|
|
137
|
+
]
|
|
138
|
+
return matches
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.get("/tasks", response_model=list[BackgroundTaskInfo])
|
|
142
|
+
@router.get("/tasks/list", response_model=list[BackgroundTaskInfo])
|
|
143
|
+
@router.get("/task/list", response_model=list[BackgroundTaskInfo])
|
|
144
|
+
async def list_tasks(
|
|
145
|
+
request: Request,
|
|
146
|
+
session_id: str | None = None,
|
|
147
|
+
scope: str | None = None,
|
|
148
|
+
status: str | None = None,
|
|
149
|
+
) -> list[BackgroundTaskInfo]:
|
|
150
|
+
if scope == "global":
|
|
151
|
+
# The automation deck needs one stable view across every currently
|
|
152
|
+
# loaded project instead of following whichever chat is the default.
|
|
153
|
+
async with get_session_lock(request.app.state):
|
|
154
|
+
closing = getattr(request.app.state, "closing_sessions", set())
|
|
155
|
+
sessions = [
|
|
156
|
+
session
|
|
157
|
+
for session in request.app.state.sessions.values()
|
|
158
|
+
if getattr(session, "session_id", None) not in closing
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
result: list[BackgroundTaskInfo] = []
|
|
162
|
+
for session in sessions:
|
|
163
|
+
async def _list_global() -> list[BackgroundTaskInfo]:
|
|
164
|
+
initializer = getattr(session, "initialize", None)
|
|
165
|
+
if callable(initializer):
|
|
166
|
+
await initializer()
|
|
167
|
+
return [
|
|
168
|
+
_task_info(item)
|
|
169
|
+
for item in _task_dicts(session)
|
|
170
|
+
if status is None or str(item.get("status") or "") == status
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
result.extend(
|
|
175
|
+
await run_session_operation(
|
|
176
|
+
request.app.state,
|
|
177
|
+
session,
|
|
178
|
+
_list_global,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
except SessionOperationRejected:
|
|
182
|
+
# A session can be closing while the deck refreshes; the next
|
|
183
|
+
# poll will reconcile it without failing the whole dashboard.
|
|
184
|
+
continue
|
|
185
|
+
return sorted(result, key=lambda item: item.updated_at, reverse=True)
|
|
186
|
+
|
|
187
|
+
session = _get_session(request, session_id)
|
|
188
|
+
if not session:
|
|
189
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
190
|
+
|
|
191
|
+
async def _list() -> list[BackgroundTaskInfo]:
|
|
192
|
+
initializer = getattr(session, "initialize", None)
|
|
193
|
+
if callable(initializer):
|
|
194
|
+
await initializer()
|
|
195
|
+
return [
|
|
196
|
+
_task_info(item)
|
|
197
|
+
for item in _task_dicts(session)
|
|
198
|
+
if status is None or str(item.get("status") or "") == status
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
return await run_session_operation(request.app.state, session, _list)
|
|
203
|
+
except SessionOperationRejected as exc:
|
|
204
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@router.get("/tasks/{task_id}", response_model=BackgroundTaskInfo)
|
|
208
|
+
async def get_task(task_id: str, request: Request, session_id: str | None = None) -> BackgroundTaskInfo:
|
|
209
|
+
session = await _get_task_session(request, session_id, task_id)
|
|
210
|
+
if not session:
|
|
211
|
+
raise HTTPException(status_code=404, detail="Task not found")
|
|
212
|
+
|
|
213
|
+
async def _get() -> list[dict[str, Any]]:
|
|
214
|
+
initializer = getattr(session, "initialize", None)
|
|
215
|
+
if callable(initializer):
|
|
216
|
+
await initializer()
|
|
217
|
+
return _matching_tasks(session, task_id)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
matches = await run_session_operation(request.app.state, session, _get)
|
|
221
|
+
except SessionOperationRejected as exc:
|
|
222
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
223
|
+
if len(matches) != 1:
|
|
224
|
+
raise HTTPException(status_code=404, detail="Task not found or selector is ambiguous")
|
|
225
|
+
return _task_info(matches[0])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@router.post("/tasks/stop")
|
|
229
|
+
@router.post("/task/stop")
|
|
230
|
+
async def stop_task(req: TaskStopRequest, request: Request) -> dict[str, Any]:
|
|
231
|
+
session = await _get_task_session(request, req.session_id, req.task_id)
|
|
232
|
+
if not session:
|
|
233
|
+
raise HTTPException(status_code=404, detail="Task not found")
|
|
234
|
+
try:
|
|
235
|
+
stopped = await run_session_operation(
|
|
236
|
+
request.app.state,
|
|
237
|
+
session,
|
|
238
|
+
lambda: session.stop_background_task(req.task_id),
|
|
239
|
+
)
|
|
240
|
+
except SessionOperationRejected as exc:
|
|
241
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
242
|
+
if not stopped:
|
|
243
|
+
raise HTTPException(status_code=404, detail=f"No running background task: {req.task_id}")
|
|
244
|
+
return {"status": "ok", "task_id": req.task_id, "stopped": True}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@router.get("/tasks/{task_id}/output")
|
|
248
|
+
async def task_output(task_id: str, request: Request, session_id: str | None = None) -> dict[str, Any]:
|
|
249
|
+
"""Read a bounded tail of a monitor/agent output file."""
|
|
250
|
+
session = await _get_task_session(request, session_id, task_id)
|
|
251
|
+
if not session:
|
|
252
|
+
raise HTTPException(status_code=404, detail="Task not found")
|
|
253
|
+
try:
|
|
254
|
+
limit = max(1, min(10_000, int(request.query_params.get("lines", "200"))))
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
raise HTTPException(status_code=400, detail="lines must be an integer")
|
|
257
|
+
async def _read_output() -> dict[str, Any] | None:
|
|
258
|
+
initializer = getattr(session, "initialize", None)
|
|
259
|
+
if callable(initializer):
|
|
260
|
+
await initializer()
|
|
261
|
+
matches = _matching_tasks(session, task_id)
|
|
262
|
+
if len(matches) != 1:
|
|
263
|
+
return None
|
|
264
|
+
path_value = matches[0].get("output_file")
|
|
265
|
+
if not path_value:
|
|
266
|
+
return {
|
|
267
|
+
"task_id": matches[0].get("task_id"),
|
|
268
|
+
"path": None,
|
|
269
|
+
"lines": [],
|
|
270
|
+
"truncated": False,
|
|
271
|
+
}
|
|
272
|
+
task = matches[0]
|
|
273
|
+
resolved_task_id = str(task.get("task_id") or "")
|
|
274
|
+
try:
|
|
275
|
+
from crabcode_core.session.storage import (
|
|
276
|
+
get_agent_transcript_path,
|
|
277
|
+
get_task_output_path,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if task.get("agent_id") or task.get("source") == "agent":
|
|
281
|
+
expected_path = get_agent_transcript_path(
|
|
282
|
+
session.cwd,
|
|
283
|
+
session.session_id,
|
|
284
|
+
str(task.get("agent_id") or resolved_task_id),
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
expected_path = get_task_output_path(
|
|
288
|
+
session.cwd,
|
|
289
|
+
session.session_id,
|
|
290
|
+
resolved_task_id,
|
|
291
|
+
)
|
|
292
|
+
output_lines, truncated = read_regular_file_tail(
|
|
293
|
+
expected_path,
|
|
294
|
+
max_lines=limit,
|
|
295
|
+
)
|
|
296
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
297
|
+
raise HTTPException(
|
|
298
|
+
status_code=404,
|
|
299
|
+
detail=f"Task output unavailable: {exc}",
|
|
300
|
+
) from exc
|
|
301
|
+
return {
|
|
302
|
+
"task_id": resolved_task_id,
|
|
303
|
+
"path": str(expected_path),
|
|
304
|
+
"lines": output_lines[-limit:],
|
|
305
|
+
"truncated": truncated,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
result = await run_session_operation(
|
|
310
|
+
request.app.state,
|
|
311
|
+
session,
|
|
312
|
+
_read_output,
|
|
313
|
+
)
|
|
314
|
+
except SessionOperationRejected as exc:
|
|
315
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
316
|
+
if result is None:
|
|
317
|
+
raise HTTPException(status_code=404, detail="Task not found or selector is ambiguous")
|
|
318
|
+
return result
|