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
|
+
"""Bounded process isolation for filesystem calls that may never return.
|
|
2
|
+
|
|
3
|
+
Neither process creation nor pipe I/O runs on the event loop or its default
|
|
4
|
+
executor. A daemon supervisor owns each child until it is reaped. Cancelled
|
|
5
|
+
children still consume a slot: a broken mount must not produce unlimited
|
|
6
|
+
processes, threads, or open pipes. SIGKILL is a request, not a successful reap.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from crabcode_core.subprocess_utils import managed_process_command, subprocess_group_options
|
|
23
|
+
from crabcode_core.types.config import DEFAULT_FILESYSTEM_TIMEOUT
|
|
24
|
+
|
|
25
|
+
_SLOTS = threading.BoundedSemaphore(8)
|
|
26
|
+
_ACTIVE_GUARD = threading.Lock()
|
|
27
|
+
_ACTIVE_ABORTS: set[Callable[[], None]] = set()
|
|
28
|
+
IO_TIMEOUT = DEFAULT_FILESYSTEM_TIMEOUT
|
|
29
|
+
|
|
30
|
+
# Do not resolve/stat package paths in the caller. Imports and path resolution
|
|
31
|
+
# happen in the child, including for editable installs on network storage.
|
|
32
|
+
_BOOTSTRAP = """
|
|
33
|
+
import sys
|
|
34
|
+
sys.path[:0] = sys.argv[1:]
|
|
35
|
+
from crabcode_core.io_worker import _worker_main
|
|
36
|
+
_worker_main()
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _kill(process: subprocess.Popen[bytes]) -> None:
|
|
41
|
+
if process.returncode is not None:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
if os.name == "nt":
|
|
45
|
+
process.kill() # The Windows supervisor owns a kill-on-close job.
|
|
46
|
+
else:
|
|
47
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
48
|
+
except ProcessLookupError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def abort_io_workers() -> None:
|
|
53
|
+
"""Request worker termination during emergency exit, without waiting."""
|
|
54
|
+
with _ACTIVE_GUARD:
|
|
55
|
+
callbacks = tuple(_ACTIVE_ABORTS)
|
|
56
|
+
for abort in callbacks:
|
|
57
|
+
abort()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def run_io(module: str, function: str, *args: Any,
|
|
61
|
+
timeout: float | None = IO_TIMEOUT, **kwargs: Any) -> Any:
|
|
62
|
+
"""Call an internal function in an expendable process; None disables the deadline."""
|
|
63
|
+
slots = _SLOTS
|
|
64
|
+
if not slots.acquire(blocking=False):
|
|
65
|
+
raise OSError("Filesystem workers are busy or still blocked; stop retrying until storage recovers")
|
|
66
|
+
loop = asyncio.get_running_loop()
|
|
67
|
+
result = loop.create_future()
|
|
68
|
+
guard = threading.Lock()
|
|
69
|
+
abandoned = False
|
|
70
|
+
process: subprocess.Popen[bytes] | None = None
|
|
71
|
+
|
|
72
|
+
def abort() -> None:
|
|
73
|
+
nonlocal abandoned
|
|
74
|
+
with guard:
|
|
75
|
+
abandoned = True
|
|
76
|
+
child = process
|
|
77
|
+
if child is not None:
|
|
78
|
+
_kill(child)
|
|
79
|
+
|
|
80
|
+
def deliver(value: Any, error: BaseException | None) -> None:
|
|
81
|
+
if not result.done():
|
|
82
|
+
if error is None:
|
|
83
|
+
result.set_result(value)
|
|
84
|
+
else:
|
|
85
|
+
result.set_exception(error)
|
|
86
|
+
|
|
87
|
+
def supervise() -> None:
|
|
88
|
+
nonlocal process
|
|
89
|
+
value, error = None, None
|
|
90
|
+
child = None
|
|
91
|
+
try:
|
|
92
|
+
payload = json.dumps([module, function, args, kwargs, os.getcwd()]).encode("utf-8")
|
|
93
|
+
# Preserve editable-install search roots, but never search the
|
|
94
|
+
# target workspace implicitly via an empty sys.path entry.
|
|
95
|
+
roots = [p for p in sys.path if p and os.path.isabs(p)]
|
|
96
|
+
child = subprocess.Popen(
|
|
97
|
+
managed_process_command([sys.executable, "-I", "-c", _BOOTSTRAP, *roots]),
|
|
98
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
99
|
+
cwd=os.path.abspath(os.sep), **subprocess_group_options(),
|
|
100
|
+
)
|
|
101
|
+
with guard:
|
|
102
|
+
process = child
|
|
103
|
+
cancelled = abandoned
|
|
104
|
+
if cancelled:
|
|
105
|
+
_kill(child)
|
|
106
|
+
payload = None # Never begin an operation after its caller left.
|
|
107
|
+
stdout, stderr = child.communicate(payload)
|
|
108
|
+
if child.returncode:
|
|
109
|
+
raise OSError(f"Filesystem worker failed ({child.returncode}): "
|
|
110
|
+
+ stderr.decode("utf-8", errors="replace")[-2000:])
|
|
111
|
+
reply = json.loads(stdout)
|
|
112
|
+
if "error" in reply:
|
|
113
|
+
raise OSError(reply["error"])
|
|
114
|
+
value = reply["result"]
|
|
115
|
+
except BaseException as exc:
|
|
116
|
+
error = exc
|
|
117
|
+
finally:
|
|
118
|
+
# Even exceptional pipe handling must not release a live child slot.
|
|
119
|
+
if child is not None:
|
|
120
|
+
try:
|
|
121
|
+
if child.poll() is None:
|
|
122
|
+
_kill(child)
|
|
123
|
+
child.wait() # Only this daemon thread can wait indefinitely.
|
|
124
|
+
finally:
|
|
125
|
+
for stream in (child.stdin, child.stdout, child.stderr):
|
|
126
|
+
if stream is not None:
|
|
127
|
+
stream.close()
|
|
128
|
+
with _ACTIVE_GUARD:
|
|
129
|
+
_ACTIVE_ABORTS.discard(abort)
|
|
130
|
+
slots.release()
|
|
131
|
+
try:
|
|
132
|
+
loop.call_soon_threadsafe(deliver, value, error)
|
|
133
|
+
except RuntimeError:
|
|
134
|
+
pass # Caller and its event loop have already exited.
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
with _ACTIVE_GUARD:
|
|
138
|
+
_ACTIVE_ABORTS.add(abort)
|
|
139
|
+
threading.Thread(target=supervise, name="crabcode-fs-worker", daemon=True).start()
|
|
140
|
+
except BaseException:
|
|
141
|
+
with _ACTIVE_GUARD:
|
|
142
|
+
_ACTIVE_ABORTS.discard(abort)
|
|
143
|
+
slots.release()
|
|
144
|
+
raise
|
|
145
|
+
try:
|
|
146
|
+
return await asyncio.wait_for(result, timeout=timeout)
|
|
147
|
+
finally:
|
|
148
|
+
if not result.done() or result.cancelled():
|
|
149
|
+
abort()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def run_file_tool(name: str, tool_input: dict[str, Any], context: Any) -> Any:
|
|
153
|
+
from crabcode_core.types.tool import ToolResult
|
|
154
|
+
|
|
155
|
+
# All session objects/callbacks stay in the parent. Snapshot metadata is
|
|
156
|
+
# explicit; filesystem changes and their backups execute in the same child.
|
|
157
|
+
fields = {key: getattr(context, key) for key in (
|
|
158
|
+
"cwd", "session_id", "snapshot_enabled", "snapshot_max_size_mb", "env",
|
|
159
|
+
"filesystem_timeout",
|
|
160
|
+
)}
|
|
161
|
+
timeout = context.filesystem_timeout
|
|
162
|
+
if name == "Bash" and "timeout" in tool_input:
|
|
163
|
+
try:
|
|
164
|
+
timeout = float(tool_input["timeout"])
|
|
165
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
166
|
+
raise ValueError
|
|
167
|
+
except (ValueError, TypeError):
|
|
168
|
+
return ToolResult(result_for_model="Error: timeout must be a positive number of seconds",
|
|
169
|
+
is_error=True)
|
|
170
|
+
try:
|
|
171
|
+
data = await run_io(__name__, "_call_file_tool", name, tool_input, fields,
|
|
172
|
+
timeout=timeout)
|
|
173
|
+
return ToolResult(**data)
|
|
174
|
+
except (OSError, asyncio.TimeoutError) as exc:
|
|
175
|
+
detail = str(exc) or (
|
|
176
|
+
f"timed out after {timeout:g}s" if timeout is not None else type(exc).__name__
|
|
177
|
+
)
|
|
178
|
+
if name in {"apply_patch", "Edit", "Write", "Bash"}:
|
|
179
|
+
detail += ". Write outcome is unknown; inspect affected files and snapshots before retrying"
|
|
180
|
+
return ToolResult(result_for_model=f"{name} filesystem operation failed: {detail}",
|
|
181
|
+
is_error=True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _call_file_tool(name: str, tool_input: dict[str, Any], fields: dict[str, Any]) -> dict:
|
|
185
|
+
from importlib import import_module
|
|
186
|
+
from crabcode_core.types.tool import ToolContext
|
|
187
|
+
|
|
188
|
+
modules = {"Read": ("file_read", "FileReadTool"), "Write": ("file_write", "FileWriteTool"),
|
|
189
|
+
"apply_patch": ("apply_patch", "ApplyPatchTool"),
|
|
190
|
+
"Edit": ("file_edit", "FileEditTool"), "Glob": ("glob", "GlobTool"),
|
|
191
|
+
"Grep": ("grep", "GrepTool"), "Bash": ("bash", "BashTool"),
|
|
192
|
+
"Image": ("image", "ImageTool")}
|
|
193
|
+
module, cls = modules[name]
|
|
194
|
+
tool = getattr(import_module(f"crabcode_core.tools.{module}"), cls)()
|
|
195
|
+
return asdict(await tool._call_local(tool_input, ToolContext(**fields)))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _worker_main() -> None:
|
|
199
|
+
import importlib
|
|
200
|
+
import inspect
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
module, function, args, kwargs, caller_cwd = json.load(sys.stdin)
|
|
204
|
+
os.chdir(caller_cwd)
|
|
205
|
+
value = getattr(importlib.import_module(module), function)(*args, **kwargs)
|
|
206
|
+
if inspect.isawaitable(value):
|
|
207
|
+
value = asyncio.run(value)
|
|
208
|
+
reply = {"result": value}
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
reply = {"error": f"{type(exc).__name__}: {exc}"}
|
|
211
|
+
sys.stdout.write(json.dumps(reply, ensure_ascii=True))
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Shared runtime logging configuration for CrabCode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import copy
|
|
8
|
+
import queue
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from crabcode_core.paths import get_config_home
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from crabcode_core.types.config import LoggingSettings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
LOG_NAMESPACE = "crabcode"
|
|
20
|
+
LOG_KEY = "crabcode"
|
|
21
|
+
LOG_FILE_NAME = "crabcode.log"
|
|
22
|
+
LOG_INDEX_NAME = "index.json"
|
|
23
|
+
LOGS_DIR_NAME = ".crabcode/logs"
|
|
24
|
+
|
|
25
|
+
_configured_signature: tuple[str, str, str] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _AsyncFileHandler(logging.Handler):
|
|
29
|
+
"""Keep disk writes and traceback source lookups off application threads.
|
|
30
|
+
|
|
31
|
+
A stalled sink has bounded memory and a daemon owner. It must never hold
|
|
32
|
+
the logging handler lock or make logging.shutdown wait for remote storage.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: Path) -> None:
|
|
36
|
+
super().__init__()
|
|
37
|
+
self._path = path
|
|
38
|
+
self._records: queue.Queue[logging.LogRecord | None] = queue.Queue(maxsize=1024)
|
|
39
|
+
self._stopped = threading.Event()
|
|
40
|
+
self._writer = threading.Thread(target=self._write, name="crabcode-log-writer", daemon=True)
|
|
41
|
+
self._writer.start()
|
|
42
|
+
|
|
43
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
44
|
+
if self._stopped.is_set():
|
|
45
|
+
return
|
|
46
|
+
try:
|
|
47
|
+
# Formatting exceptions can read source files; do it in the writer.
|
|
48
|
+
self._records.put_nowait(copy.copy(record))
|
|
49
|
+
except queue.Full:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def _write(self) -> None:
|
|
53
|
+
try:
|
|
54
|
+
with open(self._path, "a", encoding="utf-8") as stream:
|
|
55
|
+
while True:
|
|
56
|
+
try:
|
|
57
|
+
record = self._records.get(timeout=0.1)
|
|
58
|
+
except queue.Empty:
|
|
59
|
+
if self._stopped.is_set():
|
|
60
|
+
break
|
|
61
|
+
continue
|
|
62
|
+
if record is None:
|
|
63
|
+
break
|
|
64
|
+
stream.write(self.format(record) + "\n")
|
|
65
|
+
stream.flush()
|
|
66
|
+
except Exception:
|
|
67
|
+
# Do not recursively log a failed/full logging sink.
|
|
68
|
+
self._stopped.set()
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._stopped.set()
|
|
72
|
+
try:
|
|
73
|
+
self._records.put_nowait(None)
|
|
74
|
+
except queue.Full:
|
|
75
|
+
pass
|
|
76
|
+
if threading.current_thread() is not self._writer:
|
|
77
|
+
self._writer.join(timeout=0.2)
|
|
78
|
+
super().close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
82
|
+
"""Return a logger under the shared CrabCode namespace."""
|
|
83
|
+
if not name:
|
|
84
|
+
return logging.getLogger(LOG_NAMESPACE)
|
|
85
|
+
if name.startswith(f"{LOG_NAMESPACE}."):
|
|
86
|
+
return logging.getLogger(name)
|
|
87
|
+
return logging.getLogger(f"{LOG_NAMESPACE}.{name}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_logs_dir(cwd: str) -> Path:
|
|
91
|
+
"""Return the per-project logs directory."""
|
|
92
|
+
logs_dir = Path(cwd).resolve() / LOGS_DIR_NAME
|
|
93
|
+
try:
|
|
94
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
except OSError:
|
|
96
|
+
# cwd may be read-only (e.g. "/" on macOS) — fall back to config home
|
|
97
|
+
logs_dir = get_config_home() / "logs"
|
|
98
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
return logs_dir
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_log_path(cwd: str, settings: LoggingSettings | None = None) -> Path:
|
|
103
|
+
"""Resolve the main CrabCode log path."""
|
|
104
|
+
if settings and settings.file:
|
|
105
|
+
configured = Path(settings.file)
|
|
106
|
+
if not configured.is_absolute():
|
|
107
|
+
configured = Path(cwd).resolve() / configured
|
|
108
|
+
try:
|
|
109
|
+
configured.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
except OSError:
|
|
111
|
+
configured = get_config_home() / "logs" / configured.name
|
|
112
|
+
configured.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
return configured
|
|
114
|
+
return get_logs_dir(cwd) / LOG_FILE_NAME
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def configure_logging(cwd: str, settings: LoggingSettings | None = None) -> Path:
|
|
118
|
+
"""Configure the shared CrabCode logger to write to a project log file."""
|
|
119
|
+
global _configured_signature
|
|
120
|
+
|
|
121
|
+
from crabcode_core.types.config import LoggingSettings
|
|
122
|
+
|
|
123
|
+
logging_settings = settings or LoggingSettings()
|
|
124
|
+
level_name = logging_settings.level.upper()
|
|
125
|
+
log_path = get_log_path(cwd, logging_settings).resolve()
|
|
126
|
+
signature = (str(Path(cwd).resolve()), level_name, str(log_path))
|
|
127
|
+
logger = logging.getLogger(LOG_NAMESPACE)
|
|
128
|
+
|
|
129
|
+
if _configured_signature == signature and logger.handlers:
|
|
130
|
+
_register_log(cwd, LOG_KEY, log_path)
|
|
131
|
+
return log_path
|
|
132
|
+
|
|
133
|
+
for old_handler in logger.handlers[:]:
|
|
134
|
+
logger.removeHandler(old_handler)
|
|
135
|
+
old_handler.close()
|
|
136
|
+
logger.setLevel(getattr(logging, level_name, logging.WARNING))
|
|
137
|
+
logger.propagate = False
|
|
138
|
+
|
|
139
|
+
handler = _AsyncFileHandler(log_path)
|
|
140
|
+
handler.setLevel(logger.level)
|
|
141
|
+
handler.setFormatter(
|
|
142
|
+
logging.Formatter(
|
|
143
|
+
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
144
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
logger.addHandler(handler)
|
|
148
|
+
|
|
149
|
+
_configured_signature = signature
|
|
150
|
+
_register_log(cwd, LOG_KEY, log_path)
|
|
151
|
+
logger.debug("Logging configured: level=%s path=%s", level_name, log_path)
|
|
152
|
+
return log_path
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _register_log(cwd: str, key: str, log_path: Path) -> None:
|
|
156
|
+
"""Register a log file so the CLI /logs command can discover it."""
|
|
157
|
+
index_path = get_logs_dir(cwd) / LOG_INDEX_NAME
|
|
158
|
+
try:
|
|
159
|
+
data = json.loads(index_path.read_text(encoding="utf-8")) if index_path.exists() else {}
|
|
160
|
+
except (OSError, json.JSONDecodeError):
|
|
161
|
+
data = {}
|
|
162
|
+
if not isinstance(data, dict):
|
|
163
|
+
data = {}
|
|
164
|
+
data[key] = str(log_path.resolve())
|
|
165
|
+
index_path.write_text(
|
|
166
|
+
json.dumps(data, ensure_ascii=False, indent=2),
|
|
167
|
+
encoding="utf-8",
|
|
168
|
+
)
|