remote-coder 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/main.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
|
|
9
|
+
from app.admin.advanced_settings import FileAdvancedSettingsStore, advanced_settings_path_for_project_root
|
|
10
|
+
from app.admin.router import create_admin_router
|
|
11
|
+
from app.ai.factory import AiRunnerFactory
|
|
12
|
+
from app.config import get_settings
|
|
13
|
+
from app.git.ai_commit import AiCommitBodyGenerator
|
|
14
|
+
from app.git.branch_naming import TimestampSlugStrategy
|
|
15
|
+
from app.git.service import GitWorktreeService
|
|
16
|
+
from app.jobs.manager import JobManager
|
|
17
|
+
from app.jobs.store import SQLiteJobStore
|
|
18
|
+
from app.monitoring.log_buffer import InMemoryLogBuffer, attach_app_memory_log_handler
|
|
19
|
+
from app.monitoring.events import EventLogger
|
|
20
|
+
from app.projects.registry import (
|
|
21
|
+
ProjectRegistry,
|
|
22
|
+
compute_token_hash_prefix,
|
|
23
|
+
projects_config_path_for_settings,
|
|
24
|
+
)
|
|
25
|
+
from app.security.auth import AllowlistAuthService
|
|
26
|
+
from app.system_startup import run_startup_project_pulls
|
|
27
|
+
from app.telegram.commands import (
|
|
28
|
+
CommandContext,
|
|
29
|
+
CommandRegistry,
|
|
30
|
+
TelegramMessage,
|
|
31
|
+
build_default_commands,
|
|
32
|
+
)
|
|
33
|
+
from app.telegram.bot_instances import BotInstance, BotInstanceManager
|
|
34
|
+
from app.telegram.confirmations import InMemoryConfirmationStore
|
|
35
|
+
from app.telegram.conversation import SQLiteConversationStore
|
|
36
|
+
from app.telegram.notifier import Notifier, TelegramNotifier
|
|
37
|
+
from app.telegram.i18n import ui_message
|
|
38
|
+
from app.telegram.parser import CommandParser
|
|
39
|
+
from app.telegram.model_preferences import InMemoryModelPreferenceStore
|
|
40
|
+
from app.telegram.webhook import create_webhook_router
|
|
41
|
+
from app.telegram.webhook_registration import TelegramWebhookRegistrar
|
|
42
|
+
|
|
43
|
+
settings = get_settings()
|
|
44
|
+
_projects_path = projects_config_path_for_settings(settings.project_root, settings.projects_config_path)
|
|
45
|
+
project_registry = ProjectRegistry(_projects_path)
|
|
46
|
+
project_registry.ensure_seeded_from_settings(settings)
|
|
47
|
+
_advanced_settings_path = advanced_settings_path_for_project_root(settings.project_root)
|
|
48
|
+
advanced_settings_store = FileAdvancedSettingsStore(_advanced_settings_path)
|
|
49
|
+
advanced_settings_store.load()
|
|
50
|
+
|
|
51
|
+
log_buffer = InMemoryLogBuffer(max_entries=2000)
|
|
52
|
+
attach_app_memory_log_handler(log_buffer)
|
|
53
|
+
logging.getLogger("app").info(
|
|
54
|
+
"Remote AI Coder server (re)loaded — log buffer ready"
|
|
55
|
+
)
|
|
56
|
+
_systemlog = EventLogger("app.system", "system.lifecycle")
|
|
57
|
+
_httplog = EventLogger("app.http", "http.request")
|
|
58
|
+
|
|
59
|
+
job_store = SQLiteJobStore(settings.job_db_path)
|
|
60
|
+
model_preferences = InMemoryModelPreferenceStore(default_model=settings.default_model)
|
|
61
|
+
confirmation_store = InMemoryConfirmationStore()
|
|
62
|
+
conversation_store = SQLiteConversationStore(
|
|
63
|
+
settings.conversation_db_path,
|
|
64
|
+
advanced_settings_store=advanced_settings_store,
|
|
65
|
+
)
|
|
66
|
+
parser = CommandParser(
|
|
67
|
+
project_registry=project_registry,
|
|
68
|
+
default_model=settings.default_model,
|
|
69
|
+
model_preferences=model_preferences,
|
|
70
|
+
conversation_store=conversation_store,
|
|
71
|
+
conversation_recent_limit=settings.conversation_recent_limit,
|
|
72
|
+
advanced_settings_store=advanced_settings_store,
|
|
73
|
+
)
|
|
74
|
+
command_registry = CommandRegistry(commands=build_default_commands())
|
|
75
|
+
git_service = GitWorktreeService(base_dir=settings.worktree_base_dir)
|
|
76
|
+
command_context = CommandContext(
|
|
77
|
+
job_store=job_store,
|
|
78
|
+
default_model=settings.default_model,
|
|
79
|
+
project_registry=project_registry,
|
|
80
|
+
model_preferences=model_preferences,
|
|
81
|
+
project_name=None,
|
|
82
|
+
git_service=git_service,
|
|
83
|
+
git_remote_name=settings.git_remote_name,
|
|
84
|
+
conversation_store=conversation_store,
|
|
85
|
+
confirmation_store=confirmation_store,
|
|
86
|
+
advanced_settings_store=advanced_settings_store,
|
|
87
|
+
)
|
|
88
|
+
runner_factory = AiRunnerFactory(codex_sandbox=settings.codex_sandbox)
|
|
89
|
+
branch_strategy = TimestampSlugStrategy()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_bot_instance(record):
|
|
93
|
+
return BotInstance(
|
|
94
|
+
project_name=record.name,
|
|
95
|
+
token_hash=compute_token_hash_prefix(record.bot_token.get_secret_value()),
|
|
96
|
+
notifier=TelegramNotifier(record.bot_token.get_secret_value(), advanced_settings_store),
|
|
97
|
+
auth_service=AllowlistAuthService(set(record.allowed_chat_ids), set(record.allowed_user_ids)),
|
|
98
|
+
command_context=command_context,
|
|
99
|
+
webhook_secret=record.webhook_secret.get_secret_value() if record.webhook_secret else None,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
bot_instance_manager = BotInstanceManager(_build_bot_instance)
|
|
104
|
+
for project in project_registry.list_projects():
|
|
105
|
+
if project.enabled:
|
|
106
|
+
bot_instance_manager.register(project)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _notifier_for_project(project_name: str) -> Notifier:
|
|
110
|
+
instance = bot_instance_manager.get_by_name(project_name)
|
|
111
|
+
if instance is None:
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
"No Telegram notifier bot instance found for project. "
|
|
114
|
+
f"project={project_name!r}"
|
|
115
|
+
)
|
|
116
|
+
return instance.notifier
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
job_manager = JobManager(
|
|
120
|
+
settings=settings,
|
|
121
|
+
job_store=job_store,
|
|
122
|
+
git_service=git_service,
|
|
123
|
+
runner_factory=runner_factory,
|
|
124
|
+
branch_strategy=branch_strategy,
|
|
125
|
+
notifier_resolver=_notifier_for_project,
|
|
126
|
+
project_registry=project_registry,
|
|
127
|
+
advanced_settings_store=advanced_settings_store,
|
|
128
|
+
ai_commit_body_generator=AiCommitBodyGenerator(),
|
|
129
|
+
)
|
|
130
|
+
command_context.job_manager = job_manager
|
|
131
|
+
webhook_registrar = (
|
|
132
|
+
TelegramWebhookRegistrar(
|
|
133
|
+
settings.telegram_webhook_public_base_url,
|
|
134
|
+
bot_commands=command_registry.bot_commands(advanced_settings_store.get().ui_language),
|
|
135
|
+
)
|
|
136
|
+
if settings.telegram_webhook_public_base_url
|
|
137
|
+
else None
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@asynccontextmanager
|
|
141
|
+
async def lifespan(_app: FastAPI):
|
|
142
|
+
instances = bot_instance_manager.list_all()
|
|
143
|
+
startup_chat_total = sum(len(inst.auth_service.allowed_chat_ids) for inst in instances)
|
|
144
|
+
_systemlog.info(
|
|
145
|
+
"lifespan startup notifying allowed chats count=%d projects=%d default_model=%s",
|
|
146
|
+
startup_chat_total,
|
|
147
|
+
len(project_registry.list_projects()),
|
|
148
|
+
settings.default_model.value,
|
|
149
|
+
)
|
|
150
|
+
adv = advanced_settings_store.get()
|
|
151
|
+
await asyncio.to_thread(
|
|
152
|
+
run_startup_project_pulls,
|
|
153
|
+
pull_projects_on_server_startup_enabled=adv.pull_projects_on_server_startup_enabled,
|
|
154
|
+
project_registry=project_registry,
|
|
155
|
+
git_service=git_service,
|
|
156
|
+
remote=settings.git_remote_name,
|
|
157
|
+
system_log=_systemlog,
|
|
158
|
+
)
|
|
159
|
+
if adv.server_lifecycle_notify_enabled:
|
|
160
|
+
for instance in instances:
|
|
161
|
+
ctx = replace(instance.command_context, project_name=instance.project_name)
|
|
162
|
+
bot_notifier = instance.notifier
|
|
163
|
+
for chat_id in instance.auth_service.allowed_chat_ids:
|
|
164
|
+
try:
|
|
165
|
+
response = command_registry.dispatch_rich(
|
|
166
|
+
TelegramMessage(chat_id=chat_id, user_id=None, text="/start"),
|
|
167
|
+
ctx,
|
|
168
|
+
)
|
|
169
|
+
if response:
|
|
170
|
+
text = ui_message(
|
|
171
|
+
"server.started",
|
|
172
|
+
"✅ Remote AI Coder server started.\n{body}",
|
|
173
|
+
body=response.text,
|
|
174
|
+
)
|
|
175
|
+
if response.inline_buttons:
|
|
176
|
+
bot_notifier.send_with_buttons(chat_id, text, response.inline_buttons)
|
|
177
|
+
else:
|
|
178
|
+
bot_notifier.send_text(chat_id, text)
|
|
179
|
+
_systemlog.info("startup notification sent", chat_id=chat_id)
|
|
180
|
+
except Exception:
|
|
181
|
+
_systemlog.exception("startup notification failed", chat_id=chat_id)
|
|
182
|
+
else:
|
|
183
|
+
_systemlog.info("lifespan startup notify disabled by settings")
|
|
184
|
+
yield
|
|
185
|
+
shutdown_instances = bot_instance_manager.list_all()
|
|
186
|
+
shutdown_chat_total = sum(len(inst.auth_service.allowed_chat_ids) for inst in shutdown_instances)
|
|
187
|
+
_systemlog.info("lifespan shutdown notifying allowed chats count=%d", shutdown_chat_total)
|
|
188
|
+
if advanced_settings_store.get().server_lifecycle_notify_enabled:
|
|
189
|
+
for instance in shutdown_instances:
|
|
190
|
+
bot_notifier = instance.notifier
|
|
191
|
+
for chat_id in instance.auth_service.allowed_chat_ids:
|
|
192
|
+
try:
|
|
193
|
+
bot_notifier.send_text(
|
|
194
|
+
chat_id,
|
|
195
|
+
ui_message(
|
|
196
|
+
"server.shutdown",
|
|
197
|
+
"🔴 Remote AI Coder server connection closed.",
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
_systemlog.info("shutdown notification sent", chat_id=chat_id)
|
|
201
|
+
except Exception:
|
|
202
|
+
_systemlog.exception("shutdown notification failed", chat_id=chat_id)
|
|
203
|
+
else:
|
|
204
|
+
_systemlog.info("lifespan shutdown notify disabled by settings")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
app = FastAPI(title="Remote AI Coder", lifespan=lifespan)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.middleware("http")
|
|
211
|
+
async def log_http_request(request: Request, call_next):
|
|
212
|
+
start = time.perf_counter()
|
|
213
|
+
path = request.url.path
|
|
214
|
+
method = request.method
|
|
215
|
+
client_host = request.client.host if request.client else "-"
|
|
216
|
+
_httplog.info("request start method=%s path=%s client=%s", method, path, client_host)
|
|
217
|
+
try:
|
|
218
|
+
response = await call_next(request)
|
|
219
|
+
except Exception:
|
|
220
|
+
dur_ms = int((time.perf_counter() - start) * 1000)
|
|
221
|
+
_httplog.exception(
|
|
222
|
+
"request failed method=%s path=%s client=%s dur_ms=%d",
|
|
223
|
+
method,
|
|
224
|
+
path,
|
|
225
|
+
client_host,
|
|
226
|
+
dur_ms,
|
|
227
|
+
)
|
|
228
|
+
raise
|
|
229
|
+
dur_ms = int((time.perf_counter() - start) * 1000)
|
|
230
|
+
_httplog.info(
|
|
231
|
+
"request done method=%s path=%s status=%d dur_ms=%d client=%s",
|
|
232
|
+
method,
|
|
233
|
+
path,
|
|
234
|
+
response.status_code,
|
|
235
|
+
dur_ms,
|
|
236
|
+
client_host,
|
|
237
|
+
)
|
|
238
|
+
return response
|
|
239
|
+
app.include_router(
|
|
240
|
+
create_admin_router(
|
|
241
|
+
settings,
|
|
242
|
+
project_registry,
|
|
243
|
+
advanced_settings_store,
|
|
244
|
+
log_buffer,
|
|
245
|
+
conversation_store,
|
|
246
|
+
bot_instance_manager=bot_instance_manager,
|
|
247
|
+
webhook_registrar=webhook_registrar,
|
|
248
|
+
bot_commands_builder=command_registry.bot_commands,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
app.include_router(
|
|
252
|
+
create_webhook_router(
|
|
253
|
+
bot_instance_manager=bot_instance_manager,
|
|
254
|
+
parser=parser,
|
|
255
|
+
command_registry=command_registry,
|
|
256
|
+
job_manager=job_manager,
|
|
257
|
+
job_store=job_store,
|
|
258
|
+
conversation_store=conversation_store,
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.get("/health")
|
|
264
|
+
def health() -> dict[str, str]:
|
|
265
|
+
return {"status": "ok"}
|
app/models.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UiLanguage(StrEnum):
|
|
5
|
+
ENGLISH = "en"
|
|
6
|
+
KOREAN = "ko"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ModelName(StrEnum):
|
|
10
|
+
CLAUDE = "claude"
|
|
11
|
+
CODEX = "codex"
|
|
12
|
+
GEMINI = "gemini"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CodexSandboxMode(StrEnum):
|
|
16
|
+
"""Matches Codex CLI `--sandbox`; remote-coder defaults to workspace-write."""
|
|
17
|
+
|
|
18
|
+
READ_ONLY = "read-only"
|
|
19
|
+
WORKSPACE_WRITE = "workspace-write"
|
|
20
|
+
DANGER_FULL_ACCESS = "danger-full-access"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from app.monitoring.git import format_branch_monitor, format_worktree_monitor
|
|
2
|
+
from app.monitoring.memory import format_memory_monitor
|
|
3
|
+
from app.monitoring.model import format_model_monitor
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"format_branch_monitor",
|
|
7
|
+
"format_memory_monitor",
|
|
8
|
+
"format_model_monitor",
|
|
9
|
+
"format_worktree_monitor",
|
|
10
|
+
]
|
app/monitoring/code.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_CODE_SUFFIXES: frozenset[str] = frozenset(
|
|
8
|
+
{
|
|
9
|
+
".py",
|
|
10
|
+
".pyi",
|
|
11
|
+
".pyx",
|
|
12
|
+
".md",
|
|
13
|
+
".toml",
|
|
14
|
+
".yaml",
|
|
15
|
+
".yml",
|
|
16
|
+
".json",
|
|
17
|
+
".rs",
|
|
18
|
+
".go",
|
|
19
|
+
".c",
|
|
20
|
+
".h",
|
|
21
|
+
".cpp",
|
|
22
|
+
".hpp",
|
|
23
|
+
".java",
|
|
24
|
+
".kt",
|
|
25
|
+
".swift",
|
|
26
|
+
".ts",
|
|
27
|
+
".tsx",
|
|
28
|
+
".js",
|
|
29
|
+
".jsx",
|
|
30
|
+
".mjs",
|
|
31
|
+
".cjs",
|
|
32
|
+
".css",
|
|
33
|
+
".scss",
|
|
34
|
+
".html",
|
|
35
|
+
".htm",
|
|
36
|
+
".vue",
|
|
37
|
+
".svelte",
|
|
38
|
+
".sql",
|
|
39
|
+
".sh",
|
|
40
|
+
".bash",
|
|
41
|
+
".zsh",
|
|
42
|
+
".dockerfile",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_SKIP_DIR_NAMES: frozenset[str] = frozenset(
|
|
47
|
+
{
|
|
48
|
+
".git",
|
|
49
|
+
".remote-coder",
|
|
50
|
+
"__pycache__",
|
|
51
|
+
".venv",
|
|
52
|
+
"venv",
|
|
53
|
+
".tox",
|
|
54
|
+
"node_modules",
|
|
55
|
+
".mypy_cache",
|
|
56
|
+
".pytest_cache",
|
|
57
|
+
".ruff_cache",
|
|
58
|
+
".idea",
|
|
59
|
+
".vscode",
|
|
60
|
+
"dist",
|
|
61
|
+
"build",
|
|
62
|
+
".eggs",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ProjectCodeStats:
|
|
69
|
+
files_scanned: int
|
|
70
|
+
total_lines: int
|
|
71
|
+
skipped_binary_or_error: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def count_project_code(
|
|
75
|
+
project_root: Path,
|
|
76
|
+
*,
|
|
77
|
+
worktree_base_dir: Path | None = None,
|
|
78
|
+
max_files: int = 50_000,
|
|
79
|
+
) -> ProjectCodeStats:
|
|
80
|
+
root = project_root.resolve()
|
|
81
|
+
wt_base = worktree_base_dir.resolve() if worktree_base_dir is not None else None
|
|
82
|
+
|
|
83
|
+
files_scanned = 0
|
|
84
|
+
total_lines = 0
|
|
85
|
+
skipped = 0
|
|
86
|
+
|
|
87
|
+
for path in root.rglob("*"):
|
|
88
|
+
if files_scanned >= max_files:
|
|
89
|
+
break
|
|
90
|
+
if not path.is_file():
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
rel = path.relative_to(root)
|
|
94
|
+
except ValueError:
|
|
95
|
+
continue
|
|
96
|
+
if _should_skip_relative(rel, root, wt_base):
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
suf = path.suffix.lower()
|
|
100
|
+
if suf == "" and path.name in {"Dockerfile", "Makefile", "Justfile"}:
|
|
101
|
+
pass
|
|
102
|
+
elif suf not in _CODE_SUFFIXES:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
data = path.read_bytes()
|
|
107
|
+
except OSError:
|
|
108
|
+
skipped += 1
|
|
109
|
+
continue
|
|
110
|
+
if b"\x00" in data[:8192]:
|
|
111
|
+
skipped += 1
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
text = data.decode("utf-8")
|
|
115
|
+
except UnicodeDecodeError:
|
|
116
|
+
try:
|
|
117
|
+
text = data.decode("utf-8", errors="replace")
|
|
118
|
+
except OSError:
|
|
119
|
+
skipped += 1
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
line_count = 1 + text.count("\n") if text else 0
|
|
123
|
+
total_lines += line_count
|
|
124
|
+
files_scanned += 1
|
|
125
|
+
|
|
126
|
+
return ProjectCodeStats(
|
|
127
|
+
files_scanned=files_scanned,
|
|
128
|
+
total_lines=total_lines,
|
|
129
|
+
skipped_binary_or_error=skipped,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def format_code_monitor(stats: ProjectCodeStats, project_name: str, root: Path) -> str:
|
|
134
|
+
return "\n".join(
|
|
135
|
+
[
|
|
136
|
+
"Code size (estimated)",
|
|
137
|
+
f"Project: {project_name}",
|
|
138
|
+
f"root: {root}",
|
|
139
|
+
f"Code files scanned: {stats.files_scanned}",
|
|
140
|
+
f"Total lines (approx): {stats.total_lines}",
|
|
141
|
+
f"Skipped (binary/read errors): {stats.skipped_binary_or_error}",
|
|
142
|
+
"",
|
|
143
|
+
"Note: only extension-based text files are included. Large repositories may be partially counted when limits are reached.",
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _should_skip_relative(rel: Path, root: Path, wt_base: Path | None) -> bool:
|
|
149
|
+
for p in rel.parts[:-1]:
|
|
150
|
+
if p in _SKIP_DIR_NAMES:
|
|
151
|
+
return True
|
|
152
|
+
if p.endswith(".egg-info"):
|
|
153
|
+
return True
|
|
154
|
+
if wt_base is None:
|
|
155
|
+
return False
|
|
156
|
+
try:
|
|
157
|
+
candidate = (root / rel).resolve()
|
|
158
|
+
candidate.relative_to(wt_base)
|
|
159
|
+
return True
|
|
160
|
+
except ValueError:
|
|
161
|
+
return False
|
app/monitoring/events.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from app.monitoring.log_buffer import LOG_RECORD_CONTEXT_KEYS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EventLogger:
|
|
10
|
+
def __init__(self, logger_name: str, category: str) -> None:
|
|
11
|
+
self._logger = logging.getLogger(logger_name)
|
|
12
|
+
self._category = category
|
|
13
|
+
|
|
14
|
+
def _extra(self, context: dict[str, Any]) -> dict[str, Any]:
|
|
15
|
+
extra: dict[str, Any] = {"category": self._category}
|
|
16
|
+
for k in LOG_RECORD_CONTEXT_KEYS:
|
|
17
|
+
if k == "category":
|
|
18
|
+
continue
|
|
19
|
+
if k in context and context[k] is not None:
|
|
20
|
+
extra[k] = context[k]
|
|
21
|
+
return extra
|
|
22
|
+
|
|
23
|
+
def info(self, message: str, *args: Any, **context: Any) -> None:
|
|
24
|
+
self._logger.info(message, *args, extra=self._extra(context))
|
|
25
|
+
|
|
26
|
+
def warning(self, message: str, *args: Any, **context: Any) -> None:
|
|
27
|
+
self._logger.warning(message, *args, extra=self._extra(context))
|
|
28
|
+
|
|
29
|
+
def error(self, message: str, *args: Any, **context: Any) -> None:
|
|
30
|
+
self._logger.error(message, *args, extra=self._extra(context))
|
|
31
|
+
|
|
32
|
+
def exception(self, message: str, *args: Any, **context: Any) -> None:
|
|
33
|
+
self._logger.exception(message, *args, extra=self._extra(context))
|
app/monitoring/git.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from app.git.service import GitWorktreeService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
TELEGRAM_SAFE_LEN = 3800
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_branch_monitor(
|
|
12
|
+
git: GitWorktreeService,
|
|
13
|
+
root: Path,
|
|
14
|
+
remote: str,
|
|
15
|
+
project_name: str,
|
|
16
|
+
max_len: int = TELEGRAM_SAFE_LEN,
|
|
17
|
+
) -> str:
|
|
18
|
+
try:
|
|
19
|
+
current = git.get_current_branch(root)
|
|
20
|
+
local_n = git.count_local_branches(root)
|
|
21
|
+
remote_n = git.count_remote_branches_for_remote(root, remote)
|
|
22
|
+
local_block = git.format_local_branches(root)
|
|
23
|
+
remote_block = git.format_remote_branches_for_remote(root, remote)
|
|
24
|
+
except RuntimeError as exc:
|
|
25
|
+
return f"/monitor branch failed: {exc}"
|
|
26
|
+
|
|
27
|
+
header = (
|
|
28
|
+
f"Branch monitor\n"
|
|
29
|
+
f"Project: {project_name}\n"
|
|
30
|
+
f"root: {root}\n"
|
|
31
|
+
f"Remote: {remote}\n"
|
|
32
|
+
f"Current checkout: {current}\n"
|
|
33
|
+
f"Local branches: {local_n}\n"
|
|
34
|
+
f"{remote} remote-tracking branches: {remote_n}\n\n"
|
|
35
|
+
)
|
|
36
|
+
body = f"[Local]\n{local_block}\n\n[{remote} remote]\n{remote_block}"
|
|
37
|
+
text = header + body
|
|
38
|
+
if len(text) > max_len:
|
|
39
|
+
text = text[:max_len].rstrip() + "\n\n...(truncated for message length)"
|
|
40
|
+
return text
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_worktree_monitor(
|
|
44
|
+
git: GitWorktreeService,
|
|
45
|
+
project_path: Path,
|
|
46
|
+
worktree_base_dir: Path,
|
|
47
|
+
project_name: str,
|
|
48
|
+
branch_prefix: str = "remote-",
|
|
49
|
+
max_detail: int = 40,
|
|
50
|
+
) -> str:
|
|
51
|
+
try:
|
|
52
|
+
entries = git.list_worktree_entries(project_path)
|
|
53
|
+
except RuntimeError as exc:
|
|
54
|
+
return f"/monitor worktrees failed: {exc}"
|
|
55
|
+
|
|
56
|
+
root = project_path.resolve()
|
|
57
|
+
managed_base = worktree_base_dir.resolve()
|
|
58
|
+
rebase_ops_base = (worktree_base_dir / "_rebase_ops").resolve()
|
|
59
|
+
|
|
60
|
+
detached_n = 0
|
|
61
|
+
managed_n = 0
|
|
62
|
+
detail_lines: list[str] = []
|
|
63
|
+
|
|
64
|
+
for wt_path, branch in entries:
|
|
65
|
+
resolved = wt_path.resolve()
|
|
66
|
+
is_root = resolved == root
|
|
67
|
+
branch_matches = branch is not None and branch.startswith(branch_prefix)
|
|
68
|
+
under_managed = GitWorktreeService._is_within(resolved, managed_base)
|
|
69
|
+
under_rebase = GitWorktreeService._is_within(resolved, rebase_ops_base)
|
|
70
|
+
is_managed = (not is_root) and (branch_matches or under_managed or under_rebase)
|
|
71
|
+
if branch is None:
|
|
72
|
+
detached_n += 1
|
|
73
|
+
if is_managed:
|
|
74
|
+
managed_n += 1
|
|
75
|
+
|
|
76
|
+
if len(detail_lines) < max_detail:
|
|
77
|
+
b_label = branch if branch is not None else "(detached)"
|
|
78
|
+
tags: list[str] = []
|
|
79
|
+
if is_root:
|
|
80
|
+
tags.append("main worktree")
|
|
81
|
+
if is_managed:
|
|
82
|
+
tags.append("managed")
|
|
83
|
+
tag_s = f" [{' '.join(tags)}]" if tags else ""
|
|
84
|
+
detail_lines.append(f"- {wt_path} → {b_label}{tag_s}")
|
|
85
|
+
|
|
86
|
+
extra = ""
|
|
87
|
+
if len(entries) > max_detail:
|
|
88
|
+
extra = f"\n...({len(entries) - max_detail} more omitted)"
|
|
89
|
+
|
|
90
|
+
lines = [
|
|
91
|
+
"Worktree monitor",
|
|
92
|
+
f"Project: {project_name}",
|
|
93
|
+
f"root: {root}",
|
|
94
|
+
f"Managed base directory (worktree_base): {managed_base}",
|
|
95
|
+
f"Total worktrees: {len(entries)}",
|
|
96
|
+
f"Detached worktrees: {detached_n}",
|
|
97
|
+
f"Managed candidates (remote-*, base, _rebase_ops): {managed_n}",
|
|
98
|
+
"",
|
|
99
|
+
"[Entries]",
|
|
100
|
+
*detail_lines,
|
|
101
|
+
extra,
|
|
102
|
+
]
|
|
103
|
+
return "\n".join(lines).strip()
|